init push
This commit is contained in:
392
cmd/api/main.go
Normal file
392
cmd/api/main.go
Normal file
@@ -0,0 +1,392 @@
|
||||
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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user