package main import ( "context" "errors" "fmt" "log" "log/slog" "os" "os/signal" "syscall" "time" "wucher/internal/app/api" "wucher/internal/config" "wucher/internal/service" _ "wucher/docs" ) var ( apiRunFn = run apiFatalFn = log.Fatal ) func configFilePath() string { if v := os.Getenv("CONFIG_FILE"); v != "" { return v } basePath := os.Getenv("CONFIG_BASE_PATH") if basePath == "" { basePath = "/tmp" } filename := os.Getenv("CONFIG_FILENAME") if filename == "" { filename = "config.generated.json" } return basePath + "/" + filename } type apiServer interface { Start(addr string) error Shutdown(ctx context.Context) error } // @title Wucher API // @version 1.0 // @description Wucher backend API (JSON:API). // @description // @description Auth Flow (Email/Password): // @description 1) POST /api/v1/auth/register // @description 2) Check email, open verify link (GET /api/v1/auth/verify-email) // @description 3) POST /api/v1/auth/login // @description - If TOTP enabled: response 202 with challenge_token // @description 4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies // @description // @description Auth Flow (TOTP setup): // @description 1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false) // @description 2) POST /api/v1/auth/totp/confirm -> enabled=true // @description // @description Auth Flow (Microsoft Entra SSO): // @description 1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft // @description 2) Microsoft redirects to /api/v1/auth/microsoft/callback // @description 3) Backend checks existing SSO mapping (no auto-register) // @description 4) If linked user exists -> set cookies + optional redirect to AUTH_SSO_SUCCESS_REDIRECT // @description 5) If mapping not linked -> login rejected (401) // @description 6) Existing authenticated user can link/unlink via /api/v1/auth/sso/link and /api/v1/auth/sso/unlink // @tag.name Complaints - Sign Off // @tag.description Corrective-action sign-off for a helicopter. After a complaint's action_taken is recorded, it is signed off here (POST /action-signoffs/sign with complaint_id); a fleet-wide sign-off (no complaint_id) is also supported. A signed sign-off is the prerequisite for creating an EASA maintenance release. // @BasePath / func main() { if err := apiRunFn(); err != nil { apiFatalFn(err) } } func run() error { appCfg, err := config.LoadFromFile(configFilePath()) if err != nil { return fmt.Errorf("load config: %w", err) } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() return runWithContext(ctx, appCfg) } func runWithContext(ctx context.Context, appCfg config.AppConfig) error { if ctx == nil { ctx = context.Background() } db, err := initializeDatabase(appCfg.MySQL) if err != nil { return err } if sqlDB, sqlErr := db.DB(); sqlErr == nil { defer sqlDB.Close() } if err := bootstrapData(db); err != nil { return err } repos, err := initializeRepositories(db) if err != nil { return err } infra, err := initializeInfrastructure(ctx, appCfg, db) if err != nil { return err } services, err := initializeServices(appCfg, repos, infra) if err != nil { return err } if err := services.MasterSettings.SeedMicrosoftEntraConfigIfMissing(ctx, appCfg.MicrosoftSSO); err != nil { return fmt.Errorf("seed microsoft entra master settings: %w", err) } handlersSet, err := initializeHandlers(services, infra) if err != nil { return err } if err := initializeRealtimeComponents(ctx, repos, services, infra); err != nil { return err } app, addr, err := buildHTTPServer(appCfg, services, handlersSet, infra) if err != nil { return err } srv := api.NewServer(app, api.WithShutdownTimeout(appCfg.HTTP.ShutdownTimeout)) infra.Logger.Info("api listening", "addr", addr, "queue_backend", "sqs", "queue_outbox_enabled", appCfg.Queue.OutboxEnabled, ) if err := serveAPI(ctx, srv, addr); err != nil { return err } infra.Logger.Info("api stopped") return nil } func serveAPI(ctx context.Context, srv apiServer, addr string) error { if srv == nil { return errors.New("api server is required") } if ctx == nil { ctx = context.Background() } serverErrCh := make(chan error, 1) go func() { serverErrCh <- srv.Start(addr) }() select { case err := <-serverErrCh: return err case <-ctx.Done(): } shutdownErr := srv.Shutdown(context.Background()) serverErr := <-serverErrCh if shutdownErr != nil { return shutdownErr } return serverErr } func startFileManagerAutoPurgeLoop( ctx context.Context, fileManagerSvc *service.FileManagerService, trashCfg config.FileManagerTrashPolicyConfig, logger *slog.Logger, ) { if fileManagerSvc == nil || !trashCfg.AutoPurgeEnabled { return } interval := trashCfg.AutoPurgeInterval if interval <= 0 { interval = time.Hour } batchSize := trashCfg.AutoPurgeBatchSize if batchSize <= 0 { batchSize = 200 } runOnce := func() { purgedFiles, purgedFolders, err := fileManagerSvc.PurgeExpiredTrash(ctx, time.Now().UTC(), batchSize) if err != nil { if logger != nil { logger.Error("file manager auto-purge run failed", "error", err) } return } if logger != nil && (purgedFiles > 0 || purgedFolders > 0) { logger.Info( "file manager auto-purge completed", "purged_files", purgedFiles, "purged_folders", purgedFolders, "batch_size", batchSize, ) } } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() runOnce() for { select { case <-ctx.Done(): return case <-ticker.C: runOnce() } } }() } func startFleetStatusAOGRecomputeLoop( ctx context.Context, fleetStatusSvc *service.FleetStatusService, logger *slog.Logger, ) { if fleetStatusSvc == nil { return } // TESTING ONLY: sweep tiap menit supaya kolom AOG + audit nyusul cepat dengan // MEL grace yang sementara dihitung dalam menit. // TODO: kembalikan ke time.Hour sebelum production. interval := time.Minute runOnce := func() { n, err := fleetStatusSvc.RecalculateAllAOG(ctx) if err != nil { if logger != nil { logger.Error("fleet status AOG recompute failed", "error", err) } return } if logger != nil { logger.Info("fleet status AOG recompute completed", "helicopters", n) } } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() runOnce() for { select { case <-ctx.Done(): return case <-ticker.C: runOnce() } } }() } func startFileManagerLifecycleCleanupLoop( ctx context.Context, fileManagerSvc *service.FileManagerService, lifecycleCfg config.FileManagerLifecycleConfig, logger *slog.Logger, ) { if fileManagerSvc == nil || !lifecycleCfg.EnableCleanupJob { return } interval := time.Hour batchSize := lifecycleCfg.CleanupBatchSize if batchSize <= 0 { batchSize = 100 } grace := time.Duration(lifecycleCfg.OrphanGraceDays) * 24 * time.Hour tempGrace := time.Duration(lifecycleCfg.TempUploadGraceHours) * time.Hour dryRun := lifecycleCfg.CleanupDryRun runOnce := func() { stats, err := fileManagerSvc.CleanupOrphanPendingFiles(ctx, time.Now().UTC(), grace, batchSize, dryRun) if err != nil { if logger != nil { logger.Error("file manager lifecycle cleanup failed", "error", err) } return } if logger != nil { logger.Info("file manager lifecycle cleanup run completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", stats.DryRun, "batch_size", batchSize, ) } tempStats, err := fileManagerSvc.CleanupStaleTempUploads(ctx, time.Now().UTC(), tempGrace, batchSize, dryRun) if err != nil { if logger != nil { logger.Error("file manager temp upload cleanup failed", "error", err) } return } if logger != nil { logger.Info("file manager temp upload cleanup run completed", "scanned", tempStats.Scanned, "deleted", tempStats.Deleted, "skipped", tempStats.Skipped, "failed", tempStats.Failed, "dry_run", tempStats.DryRun, "batch_size", batchSize, ) } draftStats, err := fileManagerSvc.CleanupStaleDraftFiles(ctx, time.Now().UTC(), tempGrace, batchSize, dryRun) if err != nil { if logger != nil { logger.Error("file manager draft cleanup failed", "error", err) } return } if logger != nil { logger.Info("file manager draft cleanup run completed", "scanned", draftStats.Scanned, "deleted", draftStats.Deleted, "skipped", draftStats.Skipped, "failed", draftStats.Failed, "dry_run", draftStats.DryRun, "batch_size", batchSize, ) } } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() runOnce() for { select { case <-ctx.Done(): return case <-ticker.C: runOnce() } } }() } func startHelicopterFileAttachmentReconcileLoop( ctx context.Context, helicopterFileSvc *service.HelicopterFileService, logger *slog.Logger, ) { if helicopterFileSvc == nil { return } interval := 2 * time.Second batchSize := 50 runOnce := func() int { finalized, err := helicopterFileSvc.FinalizePendingAttachments(ctx, batchSize) if err != nil { if logger != nil { logger.Error("helicopter file attachment reconcile failed", "error", err) } return 0 } if logger != nil && finalized > 0 { logger.Info("helicopter file attachment reconcile completed", "finalized", finalized, "batch_size", batchSize) } return finalized } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { if ctx.Err() != nil { return } if finalized := runOnce(); finalized > 0 { continue } select { case <-ctx.Done(): return case <-ticker.C: } } }() }