e5623d2f84
- Add Infrastructure Health section with CPU/Memory/Disk panels - Add Service Status section with PM2/Docker panels - Create GrafanaPanel component for iframe embedding - Build passes successfully
386 lines
14 KiB
Go
386 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/hlog"
|
|
|
|
"boc/auth"
|
|
"database/sql"
|
|
|
|
"boc/automation"
|
|
"boc/briefing"
|
|
"boc/config"
|
|
"boc/db"
|
|
"boc/employee"
|
|
"boc/handlers"
|
|
"boc/ledger"
|
|
"boc/middleware"
|
|
"boc/sms"
|
|
"boc/store"
|
|
)
|
|
|
|
// responseWriter wraps http.ResponseWriter to capture status code
|
|
type responseWriter struct {
|
|
http.ResponseWriter
|
|
statusCode int
|
|
}
|
|
|
|
func (rw *responseWriter) WriteHeader(code int) {
|
|
rw.statusCode = code
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func main() {
|
|
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
|
cfg := config.Load()
|
|
|
|
database, err := db.Connect(cfg.DBURL)
|
|
if err != nil {
|
|
logger.Fatal().Err(err).Msg("database connect failed")
|
|
}
|
|
defer database.Close()
|
|
|
|
if err := db.RunMigrations(database, cfg.MigrationsDir); err != nil {
|
|
logger.Fatal().Err(err).Msg("migrations failed")
|
|
}
|
|
|
|
_ = store.New(database)
|
|
|
|
// Initialize handlers
|
|
crmH := handlers.NewCRMHandler(database)
|
|
salesH := handlers.NewSalesHandler(database)
|
|
hrH := handlers.NewHRHandler(database)
|
|
legalH := handlers.NewLegalHandler(database)
|
|
marketingH := handlers.NewMarketingHandler(database)
|
|
supportH := handlers.NewSupportHandler(database)
|
|
analyticsH := handlers.NewAnalyticsHandler(database)
|
|
ledgerH := ledger.NewHandler()
|
|
financeV2H := handlers.NewFinanceHandlerV2()
|
|
|
|
// Briefing engine (legacy)
|
|
briefingEngine := briefing.NewBriefingEngine(database)
|
|
briefingH := handlers.NewBriefingHandler(briefingEngine)
|
|
|
|
// Real Briefing engine (med riktig data från BOC + Ledger)
|
|
ledgerDB, ledgerErr := sql.Open("postgres", cfg.LedgerDBURL)
|
|
if ledgerErr != nil {
|
|
logger.Fatal().Err(ledgerErr).Msg("Failed to connect to ledger database")
|
|
}
|
|
defer ledgerDB.Close()
|
|
realBriefingH := handlers.NewRealBriefingHandler(database, ledgerDB)
|
|
|
|
// Journal handler (totaljournal för drill-down)
|
|
journalH := handlers.NewJournalHandler(ledgerDB)
|
|
|
|
// Tenant handler (multi-tenancy)
|
|
tenantH := handlers.NewTenantHandler(database)
|
|
|
|
// Employee Lifecycle handler
|
|
employeeH := employee.NewHandler(database)
|
|
|
|
// SMS / 46elks integration
|
|
elk46Client := sms.NewClient(nil)
|
|
var smsStore sms.VerificationStore
|
|
if cfg.RedisAddr != "" {
|
|
smsStore = sms.NewRedisStore(cfg.RedisAddr)
|
|
logger.Info().Str("redis", cfg.RedisAddr).Msg("SMS Redis store initialized")
|
|
} else {
|
|
// Fallback: in-memory store (endast för dev)
|
|
logger.Warn().Msg("No Redis configured, SMS verification will not persist across restarts")
|
|
}
|
|
var twoFactor *sms.TwoFactorAuth
|
|
var notifier *sms.NotificationService
|
|
if elk46Client.IsConfigured() {
|
|
if smsStore != nil {
|
|
twoFactor = sms.NewTwoFactorAuth(elk46Client, smsStore)
|
|
}
|
|
notifier = sms.NewNotificationService(elk46Client)
|
|
logger.Info().Msg("46elks SMS integration initialized")
|
|
} else {
|
|
logger.Warn().Msg("46elks not configured (set ELK46_USERNAME and ELK46_PASSWORD)")
|
|
}
|
|
smsH := handlers.NewSMSHandler(twoFactor, notifier)
|
|
|
|
// Automation engine
|
|
autoEngine := automation.NewEngine(database, logger)
|
|
autoH := handlers.NewAutomationHandler(database, autoEngine)
|
|
|
|
// Auth: JWTService med förbättrad validering
|
|
jwtService := auth.NewJWTService(cfg.JWTSecret, "boc-auth", "boc")
|
|
_ = jwtService
|
|
|
|
// För utveckling: använd öppen auth
|
|
authMiddleware := middleware.APIKeyAuth("")
|
|
logger.Info().Msg("Development auth initialized (open access)")
|
|
|
|
// Prometheus metrics
|
|
requestDuration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
|
Name: "boc_request_duration_seconds",
|
|
Help: "Request duration in seconds",
|
|
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
|
|
}, []string{"method", "path", "status"})
|
|
|
|
requestCount := prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "boc_request_total",
|
|
Help: "Total requests",
|
|
}, []string{"method", "path", "status"})
|
|
|
|
activeUsers := prometheus.NewGauge(prometheus.GaugeOpts{
|
|
Name: "boc_active_users",
|
|
Help: "Currently active users",
|
|
})
|
|
|
|
prometheus.MustRegister(requestDuration, requestCount, activeUsers)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.CORS)
|
|
r.Use(hlog.NewHandler(logger))
|
|
r.Use(hlog.RequestIDHandler("req_id", "X-Request-ID"))
|
|
r.Use(middleware.Logger(logger))
|
|
r.Use(chimw.Recoverer)
|
|
// Metrics middleware
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
start := time.Now()
|
|
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
|
next.ServeHTTP(rw, req)
|
|
duration := time.Since(start).Seconds()
|
|
requestDuration.WithLabelValues(req.Method, req.URL.Path, fmt.Sprintf("%d", rw.statusCode)).Observe(duration)
|
|
requestCount.WithLabelValues(req.Method, req.URL.Path, fmt.Sprintf("%d", rw.statusCode)).Inc()
|
|
})
|
|
})
|
|
|
|
r.Get("/health", handlers.NewHealthHandler())
|
|
r.Get("/metrics", promhttp.Handler().ServeHTTP)
|
|
r.Get("/debug/token", handlers.DebugTokenHandler(cfg.JWTSecret))
|
|
|
|
// Auth endpoints (no auth required)
|
|
r.Post("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Generera token direkt (förenklad för nu)
|
|
token, err := jwtService.GenerateToken("3847477b-3d56-4975-9157-ae8f9ce52aa7", req.Email, "admin")
|
|
if err != nil {
|
|
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"ok": true,
|
|
"token": token,
|
|
"token_type": "Bearer",
|
|
"expires_in": 2592000, // 30 dagar
|
|
"algorithm": "HS256",
|
|
"user": map[string]string{
|
|
"id": "3847477b-3d56-4975-9157-ae8f9ce52aa7",
|
|
"email": req.Email,
|
|
"name": "Erik Svensson",
|
|
"role": "admin",
|
|
},
|
|
})
|
|
})
|
|
|
|
// Protected routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authMiddleware)
|
|
|
|
// Auth me
|
|
r.Get("/api/v1/auth/me", func(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := auth.FromContext(r.Context())
|
|
if !ok {
|
|
// För utveckling: returnera default user
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"user":{"sub":"3847477b-3d56-4975-9157-ae8f9ce52aa7","email":"erik@landvex.com","roles":["admin"]}}`))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"user":{"sub":"` + claims.Sub + `","email":"` + claims.Email + `","roles":[]}}`))
|
|
})
|
|
|
|
// CRM
|
|
r.Get("/api/v1/crm/customers", crmH.ListCustomers)
|
|
r.Post("/api/v1/crm/customers", crmH.CreateCustomer)
|
|
r.Get("/api/v1/crm/customers/{id}", crmH.GetCustomer)
|
|
r.Put("/api/v1/crm/customers/{id}", crmH.UpdateCustomer)
|
|
r.Delete("/api/v1/crm/customers/{id}", crmH.DeleteCustomer)
|
|
r.Get("/api/v1/crm/leads", crmH.ListLeads)
|
|
r.Get("/api/v1/crm/pipeline", crmH.GetPipeline)
|
|
r.Post("/api/v1/crm/interactions", crmH.CreateInteraction)
|
|
r.Get("/api/v1/crm/customers/{id}/interactions", crmH.GetCustomerInteractions)
|
|
|
|
// Sales
|
|
r.Get("/api/v1/sales/deals", salesH.ListDeals)
|
|
r.Post("/api/v1/sales/deals", salesH.CreateDeal)
|
|
r.Get("/api/v1/sales/deals/{id}", salesH.GetDeal)
|
|
r.Put("/api/v1/sales/deals/{id}", salesH.UpdateDeal)
|
|
r.Get("/api/v1/sales/mrr", salesH.GetMRR)
|
|
r.Get("/api/v1/sales/arr", salesH.GetARR)
|
|
r.Get("/api/v1/sales/products", salesH.ListProducts)
|
|
r.Post("/api/v1/sales/products", salesH.CreateProduct)
|
|
|
|
// Finance (Ledger integration)
|
|
r.Get("/api/v1/finance/balance", financeV2H.GetBalanceSheet)
|
|
r.Get("/api/v1/finance/income", financeV2H.GetIncomeStatement)
|
|
r.Get("/api/v1/finance/moms", financeV2H.GetMomsReport)
|
|
r.Get("/api/v1/finance/accounts", financeV2H.GetAccounts)
|
|
r.Get("/api/v1/finance/invoices", ledgerH.GetInvoices)
|
|
r.Get("/api/v1/finance/cashflow", ledgerH.GetCashflow)
|
|
r.Get("/api/v1/finance/budget", ledgerH.GetBudget)
|
|
r.Post("/api/v1/finance/expenses", ledgerH.CreateExpense)
|
|
r.Get("/api/v1/finance/expenses", ledgerH.ListExpenses)
|
|
r.Get("/api/v1/finance/transactions", ledgerH.GetTransactions)
|
|
|
|
// HR
|
|
r.Get("/api/v1/hr/employees", hrH.ListEmployees)
|
|
r.Post("/api/v1/hr/employees", hrH.CreateEmployee)
|
|
r.Get("/api/v1/hr/employees/{id}", hrH.GetEmployee)
|
|
r.Put("/api/v1/hr/employees/{id}", hrH.UpdateEmployee)
|
|
r.Get("/api/v1/hr/leaves", hrH.ListLeaves)
|
|
r.Post("/api/v1/hr/leaves", hrH.CreateLeave)
|
|
r.Get("/api/v1/hr/timesheets", hrH.ListTimesheets)
|
|
r.Post("/api/v1/hr/timesheets", hrH.CreateTimesheet)
|
|
|
|
// Legal
|
|
r.Get("/api/v1/legal/contracts", legalH.ListContracts)
|
|
r.Post("/api/v1/legal/contracts", legalH.CreateContract)
|
|
r.Get("/api/v1/legal/contracts/{id}", legalH.GetContract)
|
|
r.Put("/api/v1/legal/contracts/{id}", legalH.UpdateContract)
|
|
r.Get("/api/v1/legal/templates", legalH.GetContractTemplates)
|
|
r.Get("/api/v1/legal/templates/{type}", legalH.GetContractTemplate)
|
|
r.Get("/api/v1/legal/product-links", legalH.GetProductContractLinks)
|
|
|
|
// Marketing
|
|
r.Get("/api/v1/marketing/campaigns", marketingH.ListCampaigns)
|
|
r.Post("/api/v1/marketing/campaigns", marketingH.CreateCampaign)
|
|
r.Get("/api/v1/marketing/content", marketingH.ListContent)
|
|
r.Post("/api/v1/marketing/content", marketingH.CreateContent)
|
|
|
|
// Support
|
|
r.Get("/api/v1/support/tickets", supportH.ListTickets)
|
|
r.Post("/api/v1/support/tickets", supportH.CreateTicket)
|
|
r.Get("/api/v1/support/tickets/{id}", supportH.GetTicket)
|
|
r.Put("/api/v1/support/tickets/{id}", supportH.UpdateTicket)
|
|
r.Post("/api/v1/support/tickets/{id}/comments", supportH.AddComment)
|
|
r.Get("/api/v1/support/csat", supportH.GetCSAT)
|
|
|
|
// Analytics
|
|
r.Get("/api/v1/analytics/users", analyticsH.GetActiveUsers)
|
|
r.Get("/api/v1/analytics/revenue", analyticsH.GetRevenue)
|
|
r.Get("/api/v1/analytics/retention", analyticsH.GetRetention)
|
|
r.Get("/api/v1/analytics/dashboard", analyticsH.GetDashboard)
|
|
|
|
// Briefing (Intelligent Daily Briefing Engine)
|
|
r.Get("/api/v1/briefing/daily", briefingH.GetDailyBriefing)
|
|
r.Get("/api/v1/briefing/priority", briefingH.GetPriority)
|
|
r.Get("/api/v1/briefing/alerts", briefingH.GetAlerts)
|
|
r.Get("/api/v1/briefing/recommendations", briefingH.GetRecommendations)
|
|
r.Get("/api/v1/briefing/work-plan", briefingH.GetWorkPlan)
|
|
|
|
// Real Briefing (med riktig data från BOC + Ledger)
|
|
r.Get("/api/v1/briefing/real", realBriefingH.GetRealBriefing)
|
|
|
|
// Journal (Totaljournal för drill-down)
|
|
r.Get("/api/v1/journal/entries", journalH.GetJournalEntries)
|
|
r.Get("/api/v1/journal/entries/{id}", journalH.GetJournalEntry)
|
|
r.Get("/api/v1/journal/accounts/{code}/transactions", journalH.GetAccountTransactions)
|
|
r.Post("/api/v1/journal/drill-down", journalH.PostDrillDown)
|
|
|
|
// Multi-Tenancy
|
|
r.Get("/api/v1/tenants", tenantH.ListTenants)
|
|
r.Get("/api/v1/tenants/current", tenantH.GetTenant)
|
|
r.Get("/api/v1/tenants/hierarchy", tenantH.GetTenantHierarchy)
|
|
r.Post("/api/v1/tenants/switch", tenantH.SwitchTenant)
|
|
r.Get("/api/v1/tenants/summary", tenantH.GetTenantSummary)
|
|
|
|
// Employee Lifecycle
|
|
r.Get("/api/v1/employees", employeeH.ListEmployees)
|
|
r.Post("/api/v1/employees", employeeH.CreateEmployee)
|
|
r.Get("/api/v1/employees/{id}", employeeH.GetEmployee)
|
|
r.Put("/api/v1/employees/{id}", employeeH.UpdateEmployee)
|
|
r.Get("/api/v1/employees/{id}/timeline", employeeH.GetTimeline)
|
|
r.Post("/api/v1/employees/{id}/timeline", employeeH.AddTimelineEvent)
|
|
r.Get("/api/v1/employees/{id}/competences", employeeH.GetCompetences)
|
|
r.Post("/api/v1/employees/{id}/competences", employeeH.AddCompetence)
|
|
r.Get("/api/v1/employees/{id}/documents", employeeH.GetDocuments)
|
|
r.Get("/api/v1/employees/{id}/trainings", employeeH.GetTrainings)
|
|
r.Get("/api/v1/employees/{id}/tasks", employeeH.GetTasks)
|
|
r.Get("/api/v1/employees/{id}/performance", employeeH.GetPerformance)
|
|
|
|
// SMS / 46elks
|
|
r.Get("/api/v1/sms/status", smsH.SMSStatus)
|
|
r.Post("/api/v1/sms/verify/send", smsH.SendVerificationCode)
|
|
r.Post("/api/v1/sms/verify/check", smsH.VerifyCode)
|
|
r.Post("/api/v1/sms/notify", smsH.SendNotification)
|
|
|
|
// Legal (Contracts)
|
|
r.Get("/api/v1/legal/contracts", legalH.ListContracts)
|
|
r.Get("/api/v1/legal/contracts/{id}", legalH.GetContract)
|
|
r.Post("/api/v1/legal/contracts", legalH.CreateContract)
|
|
r.Put("/api/v1/legal/contracts/{id}", legalH.UpdateContract)
|
|
r.Get("/api/v1/legal/templates", legalH.GetContractTemplates)
|
|
r.Get("/api/v1/legal/templates/{type}", legalH.GetContractTemplate)
|
|
r.Get("/api/v1/legal/product-links", legalH.GetProductContractLinks)
|
|
|
|
// Automation
|
|
r.Get("/api/v1/automation/workflows", autoH.ListWorkflows)
|
|
r.Post("/api/v1/automation/workflows", autoH.CreateWorkflow)
|
|
r.Post("/api/v1/automation/workflows/{id}/trigger", autoH.TriggerWorkflow)
|
|
r.Get("/api/v1/automation/jobs", autoH.ListScheduledJobs)
|
|
r.Post("/api/v1/automation/jobs", autoH.CreateScheduledJob)
|
|
r.Get("/api/v1/automation/runs", autoH.ListRuns)
|
|
})
|
|
|
|
// WebSocket (protected)
|
|
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: r,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
logger.Info().Str("addr", srv.Addr).Msg("BOC server starting")
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Fatal().Err(err).Msg("listen error")
|
|
}
|
|
}()
|
|
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
logger.Info().Msg("shutting down")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
logger.Error().Err(err).Msg("shutdown error")
|
|
}
|
|
}
|