393 lines
9.5 KiB
Go
393 lines
9.5 KiB
Go
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:
|
|
}
|
|
}
|
|
}()
|
|
}
|