init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

392
cmd/api/main.go Normal file
View 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:
}
}
}()
}

153
cmd/api/main_test.go Normal file
View File

@@ -0,0 +1,153 @@
package main
import (
"context"
"errors"
"sync"
"testing"
)
func TestMain_RunSuccess(t *testing.T) {
origRun := apiRunFn
origFatal := apiFatalFn
t.Cleanup(func() {
apiRunFn = origRun
apiFatalFn = origFatal
})
runCalled := false
fatalCalled := false
apiRunFn = func() error {
runCalled = true
return nil
}
apiFatalFn = func(v ...any) {
fatalCalled = true
}
main()
if !runCalled {
t.Fatalf("expected main to call run function")
}
if fatalCalled {
t.Fatalf("did not expect fatal function on successful run")
}
}
func TestMain_RunErrorCallsFatal(t *testing.T) {
origRun := apiRunFn
origFatal := apiFatalFn
t.Cleanup(func() {
apiRunFn = origRun
apiFatalFn = origFatal
})
errBoom := errors.New("boom")
fatalCalled := false
var fatalArgs []any
apiRunFn = func() error {
return errBoom
}
apiFatalFn = func(v ...any) {
fatalCalled = true
fatalArgs = v
}
main()
if !fatalCalled {
t.Fatalf("expected fatal function to be called on run error")
}
if len(fatalArgs) != 1 || fatalArgs[0] != errBoom {
t.Fatalf("unexpected fatal args: %#v", fatalArgs)
}
}
func TestServeAPI_StartError(t *testing.T) {
errBoom := errors.New("boom")
srv := &stubAPIServer{startErr: errBoom}
err := serveAPI(context.Background(), srv, ":8080")
if !errors.Is(err, errBoom) {
t.Fatalf("expected %v, got %v", errBoom, err)
}
if srv.shutdownCalled {
t.Fatalf("did not expect shutdown to be called on start error")
}
}
func TestServeAPI_ShutsDownOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
srv := &stubAPIServer{
started: make(chan struct{}),
releaseStart: make(chan struct{}),
}
errCh := make(chan error, 1)
go func() {
errCh <- serveAPI(ctx, srv, ":8080")
}()
<-srv.started
cancel()
if err := <-errCh; err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if !srv.shutdownCalled {
t.Fatalf("expected shutdown to be called")
}
}
func TestServeAPI_ReturnsShutdownError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
errBoom := errors.New("shutdown failed")
srv := &stubAPIServer{
started: make(chan struct{}),
releaseStart: make(chan struct{}),
shutdownErr: errBoom,
}
errCh := make(chan error, 1)
go func() {
errCh <- serveAPI(ctx, srv, ":8080")
}()
<-srv.started
cancel()
if err := <-errCh; !errors.Is(err, errBoom) {
t.Fatalf("expected %v, got %v", errBoom, err)
}
}
type stubAPIServer struct {
startErr error
shutdownErr error
started chan struct{}
releaseStart chan struct{}
shutdownCalled bool
releaseOnce sync.Once
}
func (s *stubAPIServer) Start(string) error {
if s.started != nil {
close(s.started)
}
if s.releaseStart != nil {
<-s.releaseStart
}
return s.startErr
}
func (s *stubAPIServer) Shutdown(context.Context) error {
s.shutdownCalled = true
if s.releaseStart != nil {
s.releaseOnce.Do(func() {
close(s.releaseStart)
})
}
return s.shutdownErr
}

834
cmd/api/startup.go Normal file
View File

@@ -0,0 +1,834 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"wucher/internal/app/api"
queueapp "wucher/internal/app/queueing"
samlauth "wucher/internal/auth"
"wucher/internal/config"
filemanager "wucher/internal/domain/file_manager"
filemanagerrealtime "wucher/internal/realtime/filemanager"
helicopterfilerealtime "wucher/internal/realtime/helicopterfile"
"wucher/internal/repository/mysql"
s3repo "wucher/internal/repository/s3"
"wucher/internal/resilience"
"wucher/internal/service"
pkglogger "wucher/internal/shared/pkg/logger"
tzpkg "wucher/internal/shared/pkg/timezone"
userctx "wucher/internal/shared/pkg/userctx"
"wucher/internal/transport/http/handlers"
)
type Repositories struct {
User *mysql.UserRepository
Contact *mysql.ContactRepository
Role *mysql.RoleRepository
Helicopter *mysql.HelicopterRepository
HelicopterUsage *mysql.HelicopterUsageRepository
FlightInspection *mysql.FlightInspectionRepository
Flight *mysql.FlightRepository
FlightData *mysql.FlightDataRepository
Mission *mysql.MissionRepository
ReserveAc *mysql.ReserveAcRepository
HelicopterFile *mysql.HelicopterFileRepository
FlightInspectionFileChecklist *mysql.FlightInspectionFileChecklistRepository
BeforeFlightInspection *mysql.BeforeFlightInspectionRepository
AfterFlightInspection *mysql.AfterFlightInspectionRepository
FMReport *mysql.FMReportRepository
FlightPrepCheck *mysql.FlightPrepCheckRepository
Facility *mysql.FacilityRepository
Base *mysql.BaseRepository
Medicine *mysql.MedicineRepository
Vocation *mysql.VocationRepository
Opc *mysql.OpcRepository
ForcesPresent *mysql.ForcesPresentRepository
Hospital *mysql.HospitalRepository
HealthInsuranceCompanies *mysql.HealthInsuranceCompaniesRepository
FederalState *mysql.FederalStateRepository
ICAO *mysql.ICAORepository
Land *mysql.LandRepository
Complaint *mysql.ComplaintRepository
EASARelease *mysql.EASAReleaseRepository
ActionSignoff *mysql.ActionSignoffRepository
OtherPerson *mysql.OtherPersonRepository
MCF *mysql.MCFRepository
FleetHistory *mysql.FleetHistoryRepository
HEMSOperationalData *mysql.HEMSOperationalDataRepository
HEMSOperationCategory *mysql.HEMSOperationCategoryRepository
HEMSOperation *mysql.HEMSOperationRepository
MasterSettings *mysql.MasterSettingsRepository
FileManagerFolder *mysql.FileManagerFolderRepository
FileManagerFile *mysql.FileManagerFileRepository
FileManagerAttachment *mysql.FileManagerAttachmentRepository
DutyRoster *mysql.DutyRosterRepository
AirRescuerChecklist *mysql.AirRescuerChecklistRepository
InsurancePatientData *mysql.InsurancePatientDataRepository
PatientData *mysql.PatientDataRepository
DUL *mysql.DULRepository
FleetStatus *mysql.FleetStatusRepository
TokenStore *mysql.TokenStore
Auth *mysql.AuthRepository
Audit *mysql.AuditRepository
}
type Infrastructure struct {
DB *gorm.DB
Logger *slog.Logger
SQSBreaker *resilience.Executor
FileStorage filemanager.ObjectStorage
EmailQueue service.EmailQueue
FileQueue filemanager.FileProcessingQueue
FileStatusHub *filemanagerrealtime.Hub
HelicopterFileHub *helicopterfilerealtime.Hub
FileTrashPolicy config.FileManagerTrashPolicyConfig
FileLifecycle config.FileManagerLifecycleConfig
}
type Services struct {
User *service.UserService
Contact *service.ContactService
Role *service.RoleService
Helicopter *service.HelicopterService
HelicopterUsage *service.HelicopterUsageService
FlightInspection *service.FlightInspectionService
Flight *service.FlightService
FlightData *service.FlightDataService
Mission *service.MissionService
ReserveAc *service.ReserveAcService
Takeover *service.TakeoverService
OtherPerson *service.OtherPersonService
HelicopterFile *service.HelicopterFileService
FlightInspectionChecklist *service.FlightInspectionFileChecklistService
BeforeFlightInspection *service.BeforeFlightInspectionService
AfterFlightInspection *service.AfterFlightInspectionService
FMReport *service.FMReportService
FlightPrepCheck *service.FlightPrepCheckService
Facility *service.FacilityService
Base *service.BaseService
Medicine *service.MedicineService
Vocation *service.VocationService
Opc *service.OpcService
ForcesPresent *service.ForcesPresentService
Hospital *service.HospitalService
HealthInsuranceCompanies *service.HealthInsuranceCompaniesService
FederalState *service.FederalStateService
ICAO *service.ICAOService
Land *service.LandService
Complaint *service.ComplaintService
EASARelease *service.EASAReleaseService
ActionSignoff *service.ActionSignoffService
MCF *service.MCFService
FleetHistory *service.FleetHistoryService
HEMSOperationalData *service.HEMSOperationalDataService
HEMSOperationCategory *service.HEMSOperationCategoryService
HEMSOperation *service.HEMSOperationService
MasterSettings *service.MasterSettingsService
FileManager *service.FileManagerService
DutyRoster *service.DutyRosterService
AirRescuerChecklist *service.AirRescuerChecklistService
InsurancePatientData *service.InsurancePatientDataService
PatientData *service.PatientDataService
DUL *service.DULService
FleetStatus *service.FleetStatusService
WOPI *service.WOPIService
Auth *service.AuthService
Audit *service.AuditLogService
}
type Handlers struct {
User *handlers.UserHandler
Contact *handlers.ContactHandler
Health *handlers.HealthHandler
BuildInfo *handlers.BuildInfoHandler
Auth *handlers.AuthHandler
Role *handlers.RoleHandler
Helicopter *handlers.HelicopterHandler
HelicopterUsage *handlers.HelicopterUsageHandler
HelicopterFile *handlers.HelicopterFileHandler
Flight *handlers.FlightHandler
FlightData *handlers.FlightDataHandler
Mission *handlers.MissionHandler
FMReport *handlers.FMReportHandler
ReserveAc *handlers.ReserveAcHandler
Takeover *handlers.TakeoverHandler
OtherPerson *handlers.OtherPersonHandler
Facility *handlers.FacilityHandler
Base *handlers.BaseHandler
Medicine *handlers.MedicineHandler
Vocation *handlers.VocationHandler
Opc *handlers.OpcHandler
ForcesPresent *handlers.ForcesPresentHandler
Hospital *handlers.HospitalHandler
HealthInsuranceCompanies *handlers.HealthInsuranceCompaniesHandler
FederalState *handlers.FederalStateHandler
ICAO *handlers.ICAOHandler
Land *handlers.LandHandler
MasterSettings *handlers.MasterSettingsHandler
Branding *handlers.BrandingHandler
FileManagerFolder *handlers.FileManagerFolderHandler
FileManagerFile *handlers.FileManagerFileHandler
FileManagerAttachment *handlers.FileManagerAttachmentHandler
FileManagerEvents *handlers.FileManagerEventsHandler
HelicopterFileEvents *handlers.HelicopterFileEventsHandler
DutyRoster *handlers.DutyRosterHandler
AirRescuerChecklist *handlers.AirRescuerChecklistHandler
InsurancePatientData *handlers.InsurancePatientDataHandler
PatientData *handlers.PatientDataHandler
DUL *handlers.DULHandler
FleetStatus *handlers.FleetStatusHandler
HEMSOperationalData *handlers.HEMSOperationalDataHandler
HEMSOperationCategory *handlers.HEMSOperationCategoryHandler
HEMSOperation *handlers.HEMSOperationHandler
Complaint *handlers.ComplaintHandler
EASARelease *handlers.EASAReleaseHandler
ActionSignoff *handlers.ActionSignoffHandler
MCF *handlers.MCFHandler
}
func initializeDatabase(mysqlCfg config.MySQLConfig) (*gorm.DB, error) {
return mysql.Connect(mysqlCfg)
}
func bootstrapData(db *gorm.DB) error {
if err := mysql.AutoMigrate(db); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
if err := db.Exec("UPDATE users SET timezone = ? WHERE timezone IS NULL OR timezone = ''", tzpkg.Default).Error; err != nil {
return fmt.Errorf("seed users timezone default: %w", err)
}
if err := api.SeedRoles(db); err != nil {
return fmt.Errorf("seed roles: %w", err)
}
if err := api.SeedPermissions(db); err != nil {
return fmt.Errorf("seed permissions: %w", err)
}
if err := api.SeedRolePermissions(db); err != nil {
return fmt.Errorf("seed role permissions: %w", err)
}
if err := api.SeedBaseCategories(db); err != nil {
return fmt.Errorf("seed base categories: %w", err)
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("SEED_ROSTER_USERS")), "true") {
if err := api.SeedRosterUsers(db); err != nil {
return fmt.Errorf("seed roster users: %w", err)
}
}
return nil
}
func initializeRepositories(db *gorm.DB) (*Repositories, error) {
if db == nil {
return nil, errors.New("database is required")
}
repos := &Repositories{
User: mysql.NewUserRepository(db),
Contact: mysql.NewContactRepository(db),
Role: mysql.NewRoleRepository(db),
Helicopter: mysql.NewHelicopterRepository(db),
HelicopterUsage: mysql.NewHelicopterUsageRepository(db),
FlightInspection: mysql.NewFlightInspectionRepository(db),
Flight: mysql.NewFlightRepository(db),
FlightData: mysql.NewFlightDataRepository(db),
Mission: mysql.NewMissionRepository(db),
ReserveAc: mysql.NewReserveAcRepository(db),
HelicopterFile: mysql.NewHelicopterFileRepository(db),
FlightInspectionFileChecklist: mysql.NewFlightInspectionFileChecklistRepository(db),
BeforeFlightInspection: mysql.NewBeforeFlightInspectionRepository(db),
AfterFlightInspection: mysql.NewAfterFlightInspectionRepository(db),
FMReport: mysql.NewFMReportRepository(db),
FlightPrepCheck: mysql.NewFlightPrepCheckRepository(db),
Facility: mysql.NewFacilityRepository(db),
Base: mysql.NewBaseRepository(db),
Medicine: mysql.NewMedicineRepository(db),
Vocation: mysql.NewVocationRepository(db),
Opc: mysql.NewOpcRepository(db),
ForcesPresent: mysql.NewForcesPresentRepository(db),
Hospital: mysql.NewHospitalRepository(db),
HealthInsuranceCompanies: mysql.NewHealthInsuranceCompaniesRepository(db),
FederalState: mysql.NewFederalStateRepository(db),
ICAO: mysql.NewICAORepository(db),
Land: mysql.NewLandRepository(db),
Complaint: mysql.NewComplaintRepository(db),
EASARelease: mysql.NewEASAReleaseRepository(db),
ActionSignoff: mysql.NewActionSignoffRepository(db),
OtherPerson: mysql.NewOtherPersonRepository(db),
MCF: mysql.NewMCFRepository(db),
FleetHistory: mysql.NewFleetHistoryRepository(db),
HEMSOperationalData: mysql.NewHEMSOperationalDataRepository(db),
HEMSOperationCategory: mysql.NewHEMSOperationCategoryRepository(db),
HEMSOperation: mysql.NewHEMSOperationRepository(db),
MasterSettings: mysql.NewMasterSettingsRepository(db),
FileManagerFolder: mysql.NewFileManagerFolderRepository(db),
FileManagerFile: mysql.NewFileManagerFileRepository(db),
FileManagerAttachment: mysql.NewFileManagerAttachmentRepository(db),
DutyRoster: mysql.NewDutyRosterRepository(db),
AirRescuerChecklist: mysql.NewAirRescuerChecklistRepository(db),
InsurancePatientData: mysql.NewInsurancePatientDataRepository(db),
PatientData: mysql.NewPatientDataRepository(db),
DUL: mysql.NewDULRepository(db),
FleetStatus: mysql.NewFleetStatusRepository(db),
TokenStore: mysql.NewTokenStore(db),
Auth: mysql.NewAuthRepository(db),
Audit: mysql.NewAuditRepository(db),
}
return repos, nil
}
func initializeInfrastructure(ctx context.Context, appCfg config.AppConfig, db *gorm.DB) (*Infrastructure, error) {
appLogger := pkglogger.New(appCfg.Logging).With("component", "api")
slog.SetDefault(appLogger)
if err := config.ValidateDistinctQueueURLs(appCfg.SQS, appCfg.FileSQS); err != nil {
return nil, err
}
resCfg := appCfg.Resilience
sqsBreaker := resilience.NewExecutor("sqs", resCfg.SQS, appLogger, resilience.ClassifySQSError)
var emailQueue service.EmailQueue
if !appCfg.Queue.OutboxEnabled {
emailProducer, err := queueapp.NewEmailProducer(ctx, appCfg.Queue, appCfg.SQS, sqsBreaker, appLogger)
if err != nil {
return nil, err
}
emailQueue = service.AdaptEmailQueue(emailProducer)
}
var fileStorage filemanager.ObjectStorage
if appCfg.FileStorage.Bucket != "" && appCfg.FileStorage.Region != "" {
storage, err := s3repo.NewFileObjectStorage(ctx, appCfg.FileStorage)
if err != nil {
return nil, err
}
fileStorage = storage
}
var fileQueue filemanager.FileProcessingQueue
if !appCfg.FileQueue.OutboxEnabled && strings.TrimSpace(appCfg.FileSQS.QueueURL) != "" {
fileProducer, err := queueapp.NewFileProcessingProducer(ctx, appCfg.FileQueue, appCfg.FileSQS, sqsBreaker, appLogger)
if err != nil {
return nil, err
}
fileQueue = service.AdaptFileProcessingQueue(fileProducer)
}
return &Infrastructure{
DB: db,
Logger: appLogger,
SQSBreaker: sqsBreaker,
FileStorage: fileStorage,
EmailQueue: emailQueue,
FileQueue: fileQueue,
FileStatusHub: filemanagerrealtime.NewHub(64, appLogger),
HelicopterFileHub: helicopterfilerealtime.NewHub(64, appLogger),
FileTrashPolicy: config.LoadFileManagerTrashPolicyConfigFromEnv(),
FileLifecycle: config.LoadFileManagerLifecycleConfigFromEnv(),
}, nil
}
func initializeServices(appCfg config.AppConfig, repos *Repositories, infra *Infrastructure) (*Services, error) {
if repos == nil {
return nil, errors.New("repositories are required")
}
if infra == nil {
return nil, errors.New("infrastructure is required")
}
var protector service.SecretProtector
if p, err := service.NewAESGCMProtectorFromBase64(appCfg.Auth.TOTPSecretKeyB64); err == nil {
protector = p
}
fileManagerDeps := service.FileManagerServiceDependencies{
Storage: infra.FileStorage,
FileQueue: infra.FileQueue,
UploadPolicy: filemanager.FileUploadPolicy{},
TrashRetention: infra.FileTrashPolicy.Retention,
FileStatusEvents: repos.FileManagerFile,
Logger: infra.Logger,
EnableS3Tagging: infra.FileLifecycle.EnableS3Tagging,
}
if appCfg.FileQueue.OutboxEnabled {
fileManagerDeps.FileOutbox = repos.FileManagerFile
fileManagerDeps.FileOutboxSerializer = queueapp.NewFileProcessingSerializer(appCfg.FileQueue, appCfg.FileSQS)
}
fileManagerDeps.UploadPolicy = filemanager.FileUploadPolicy{
MaxFileSizeBytes: appCfg.FileUpload.MaxFileSizeBytes,
AllowedMimeTypes: appCfg.FileUpload.AllowedMimeTypes,
AllowedExtensions: appCfg.FileUpload.AllowedExtensions,
AllowEmptyFile: appCfg.FileUpload.AllowEmptyFile,
MaxFilenameLength: appCfg.FileUpload.MaxFilenameLength,
}
flightDataSvc := service.NewFlightDataService(repos.FlightData)
flightSvc := service.NewFlightService(repos.Flight)
fileManagerSvc := service.NewFileManagerService(repos.FileManagerFolder, repos.FileManagerFile, repos.FileManagerAttachment, fileManagerDeps)
helicopterSvc := service.NewHelicopterService(repos.Helicopter)
helicopterUsageSvc := service.NewHelicopterUsageService(repos.HelicopterUsage)
afterFlightInspectionSvc := service.NewAfterFlightInspectionService(repos.AfterFlightInspection)
services := &Services{
User: service.NewUserService(repos.User),
Role: service.NewRoleService(repos.Role),
Helicopter: helicopterSvc,
HelicopterUsage: helicopterUsageSvc,
FlightInspection: service.NewFlightInspectionService(repos.FlightInspection),
Flight: flightSvc,
FlightData: flightDataSvc,
Mission: nil,
ReserveAc: service.NewReserveAcService(repos.ReserveAc),
Takeover: service.NewTakeoverService(infra.DB),
HelicopterFile: service.NewHelicopterFileService(repos.HelicopterFile),
FlightInspectionChecklist: service.NewFlightInspectionFileChecklistService(repos.FlightInspectionFileChecklist),
BeforeFlightInspection: service.NewBeforeFlightInspectionService(repos.BeforeFlightInspection),
AfterFlightInspection: afterFlightInspectionSvc,
FMReport: service.NewFMReportService(repos.FMReport),
FlightPrepCheck: service.NewFlightPrepCheckService(repos.FlightPrepCheck, repos.FlightInspection),
Facility: service.NewFacilityService(repos.Facility),
Base: service.NewBaseService(repos.Base),
Medicine: service.NewMedicineService(repos.Medicine),
Vocation: service.NewVocationService(repos.Vocation),
Opc: service.NewOpcService(repos.Opc),
ForcesPresent: service.NewForcesPresentService(repos.ForcesPresent),
Hospital: service.NewHospitalService(repos.Hospital),
HealthInsuranceCompanies: service.NewHealthInsuranceCompaniesService(repos.HealthInsuranceCompanies),
FederalState: service.NewFederalStateService(repos.FederalState),
ICAO: service.NewICAOService(repos.ICAO),
Land: service.NewLandService(repos.Land),
Complaint: service.NewComplaintService(repos.Complaint),
EASARelease: service.NewEASAReleaseService(repos.EASARelease),
ActionSignoff: service.NewActionSignoffService(repos.ActionSignoff),
OtherPerson: service.NewOtherPersonService(repos.OtherPerson),
MCF: service.NewMCFService(repos.MCF),
FleetHistory: service.NewFleetHistoryService(repos.FleetHistory),
HEMSOperationalData: service.NewHEMSOperationalDataService(repos.HEMSOperationalData),
HEMSOperationCategory: service.NewHEMSOperationCategoryService(repos.HEMSOperationCategory),
HEMSOperation: service.NewHEMSOperationService(repos.HEMSOperation),
MasterSettings: service.NewMasterSettingsService(repos.MasterSettings, service.MasterSettingsServiceDependencies{Protector: protector}),
FileManager: fileManagerSvc,
DutyRoster: service.NewDutyRosterService(repos.DutyRoster),
AirRescuerChecklist: service.NewAirRescuerChecklistService(repos.AirRescuerChecklist),
InsurancePatientData: service.NewInsurancePatientDataService(repos.InsurancePatientData),
PatientData: service.NewPatientDataService(repos.PatientData),
DUL: service.NewDULService(repos.DUL),
Audit: service.NewAuditLogService(repos.Audit, service.AuditLogServiceDependencies{QueueSize: appCfg.Audit.QueueSize, Workers: appCfg.Audit.Workers, IPEncryptionKey: appCfg.Auth.IPEncryptionKey}),
WOPI: service.NewWOPIService(service.WOPIConfig{
TokenSecret: appCfg.WOPI.TokenSecret,
ProofSecret: appCfg.WOPI.ProofSecret,
DiscoveryURL: appCfg.WOPI.DiscoveryURL,
PublicBaseURL: appCfg.WOPI.PublicBaseURL,
EditActionURL: appCfg.WOPI.EditActionURL,
TokenTTL: appCfg.WOPI.TokenTTL,
MaxBodyBytes: appCfg.WOPI.MaxBodyBytes,
RequireProof: appCfg.WOPI.RequireProof,
}),
}
services.Mission = service.NewMissionService(repos.Mission, flightSvc, flightDataSvc, afterFlightInspectionSvc)
services.FleetStatus = service.NewFleetStatusService(repos.FleetStatus, fileManagerSvc, helicopterSvc, services.Complaint).WithHistory(services.FleetHistory).WithUsage(services.HelicopterUsage)
flightDataSvc.WithAOGRecalculator(services.FleetStatus)
flightDataSvc.WithMissionResolver(services.Mission)
flightDataSvc.WithFlightLookup(services.Flight)
flightDataSvc.WithFMReportLookup(services.FMReport)
flightDataSvc.WithAfterFlightInspectionLookup(services.AfterFlightInspection)
services.FMReport.WithUsageRefresher(services.HelicopterUsage)
msSSO := service.NewMicrosoftSSO(appCfg.MicrosoftSSO).
WithExecutor(resilience.NewExecutor("microsoft_sso", appCfg.Resilience.MicrosoftSSO, infra.Logger, resilience.ClassifyMicrosoftSSOError)).
WithConfigResolver(func(ctx context.Context) (config.MicrosoftSSOConfig, error) {
cfg, err := services.MasterSettings.LoadMicrosoftEntraRuntimeConfig(ctx)
if err == nil {
return cfg, nil
}
// Fallback for local/dev when master settings are missing or stale.
// This prevents SSO flows from failing hard when DB-stored settings are invalid.
if service.IsMicrosoftSSOConfigComplete(appCfg.MicrosoftSSO) {
if infra.Logger != nil {
infra.Logger.Warn("microsoft sso runtime config fallback to env", "reason", err.Error())
}
return appCfg.MicrosoftSSO, nil
}
return config.MicrosoftSSOConfig{}, err
})
authDeps := service.AuthServiceDependencies{
SSO: msSSO,
Protector: protector,
Config: appCfg.Auth,
Audit: services.Audit,
EmailBranding: service.NewAuthEmailBrandingResolver(services.MasterSettings, services.FileManager, infra.FileStorage),
EmailOTPEnabled: services.MasterSettings.IsLoginEmailOTPEnabled,
DisableLegacyNoopEmailQueue: true,
}
if appCfg.Queue.OutboxEnabled {
authDeps.EmailOutbox = repos.Auth
authDeps.EmailSerializer = queueapp.NewOutboxSerializer(appCfg.Queue, appCfg.SQS)
}
services.Auth = service.NewAuthService(
repos.Auth,
repos.Role,
repos.TokenStore,
infra.EmailQueue,
authDeps,
)
services.Contact = service.NewContactService(repos.Contact, services.Auth)
services.HelicopterFile.
WithFileManagerService(services.FileManager).
WithHelicopterFileEvents(infra.HelicopterFileHub)
if infra.FileStorage != nil && !appCfg.FileQueue.OutboxEnabled && strings.TrimSpace(appCfg.FileSQS.QueueURL) == "" {
return nil, errors.New("FILE_SQS_QUEUE_URL is required when FILE_QUEUE_OUTBOX_ENABLED=false and file manager upload is enabled")
}
return services, nil
}
func initializeHandlers(services *Services, infra *Infrastructure) (*Handlers, error) {
if services == nil {
return nil, errors.New("services are required")
}
if infra == nil {
return nil, errors.New("infrastructure is required")
}
fleetStatusHandler := handlers.NewFleetStatusHandler(services.FleetStatus, handlers.FleetStatusDeps{
Helicopter: services.Helicopter,
Complaint: services.Complaint,
EASA: services.EASARelease,
History: services.FleetHistory,
MCF: services.MCF,
Signoff: services.ActionSignoff,
Storage: infra.FileStorage,
})
fmReportFlightHandler := handlers.NewFlightHandler(services.Flight).
WithAuthService(services.Auth).
WithAfterFlightInspectionService(services.AfterFlightInspection).
WithFMReportService(services.FMReport).
WithFlightDataService(services.FlightData).
WithTakeoverService(service.NewTakeoverModuleService(mysql.NewTakeoverRepository(infra.DB))).
WithHelicopterService(services.Helicopter).
WithReserveAcService(services.ReserveAc).
WithMissionService(services.Mission)
services.FMReport.WithFleetHistoryBuilder(handlers.NewFMReportFleetHistoryBuilder(fleetStatusHandler, fmReportFlightHandler))
services.FMReport.WithReportCodeResolver(fmReportFlightHandler)
return &Handlers{
User: handlers.NewUserHandler(services.User).
WithFileManagerService(services.FileManager).
WithFileStorage(infra.FileStorage),
Contact: handlers.NewContactHandler(services.Contact, services.Auth).
WithFileManagerService(services.FileManager).
WithFileStorage(infra.FileStorage),
Health: handlers.NewHealthHandler(),
BuildInfo: handlers.NewBuildInfoHandler(),
Auth: handlers.NewAuthHandler(services.Auth).WithFileStorage(infra.FileStorage).WithContactService(services.Contact),
Role: handlers.NewRoleHandler(services.Role),
Helicopter: handlers.NewHelicopterHandler(services.Helicopter).
WithFileManagerService(services.FileManager).
WithFileStorage(infra.FileStorage).
WithReserveAcService(services.ReserveAc).
WithFleetStatusService(services.FleetStatus).
WithPINVerifier(services.Auth).
WithComplaintService(services.Complaint).
WithMCFService(services.MCF).
WithHistoryService(services.FleetHistory),
HelicopterUsage: handlers.NewHelicopterUsageHandler(services.HelicopterUsage),
HelicopterFile: handlers.NewHelicopterFileHandler(services.HelicopterFile).
WithFileManagerService(services.FileManager).
WithHelicopterService(services.Helicopter).
WithTakeoverService(services.Takeover).
WithHelicopterFileEvents(infra.HelicopterFileHub).
WithFileStorage(infra.FileStorage),
Flight: handlers.NewFlightHandler(services.Flight).
WithAuthService(services.Auth).
WithAfterFlightInspectionService(services.AfterFlightInspection).
WithFMReportService(services.FMReport).
WithFlightDataService(services.FlightData).
WithTakeoverService(service.NewTakeoverModuleService(mysql.NewTakeoverRepository(infra.DB))).
WithHelicopterService(services.Helicopter).
WithReserveAcService(services.ReserveAc).
WithMissionService(services.Mission),
FlightData: handlers.NewFlightDataHandler(services.FlightData).
WithMissionService(services.Mission).
WithUserService(services.User).
WithHospitalService(services.Hospital).
WithICAOService(services.ICAO).
WithFacilityService(services.Facility),
Mission: handlers.NewMissionHandler(services.Mission).WithFlightDataService(services.FlightData).WithFileStorage(infra.FileStorage).WithFileManagerService(services.FileManager),
FMReport: handlers.NewFMReportHandler(services.FMReport).
WithFlightHandler(fmReportFlightHandler).
WithMissionHandler(handlers.NewMissionHandler(services.Mission).WithFlightDataService(services.FlightData).WithFileStorage(infra.FileStorage).WithFileManagerService(services.FileManager)).
WithFlightInspectionService(services.FlightInspection).
WithAfterFlightInspectionService(services.AfterFlightInspection).
WithHelicopterUsageService(services.HelicopterUsage).
WithFleetStatusHandler(fleetStatusHandler).
WithTakeoverService(services.Takeover).
WithHelicopterService(services.Helicopter).
WithFileStorage(infra.FileStorage).
WithFileManagerService(services.FileManager),
ReserveAc: handlers.NewReserveAcHandler(services.ReserveAc).
WithFlightService(services.Flight).
WithFlightDataService(services.FlightData).
WithMissionService(services.Mission).
WithDutyRosterService(services.DutyRoster).
WithHelicopterService(services.Helicopter).
WithFlightInspectionService(services.FlightInspection).
WithBeforeFlightInspectionService(services.BeforeFlightInspection).
WithAfterFlightInspectionService(services.AfterFlightInspection).
WithFMReportService(services.FMReport).
WithFlightPrepCheckService(services.FlightPrepCheck).
WithHelicopterFileService(services.HelicopterFile).
WithFlightInspectionFileChecklistService(services.FlightInspectionChecklist).
WithFileStorage(infra.FileStorage),
Takeover: handlers.NewTakeoverHandler(infra.DB).
WithAuthService(services.Auth).
WithBaseService(services.Base).
WithFlightService(services.Flight).
WithFileManagerService(services.FileManager).
WithTakeoverAcService(service.NewTakeoverModuleService(mysql.NewTakeoverRepository(infra.DB))).
WithReserveAcService(services.ReserveAc).
WithDutyRosterService(services.DutyRoster).
WithHelicopterService(services.Helicopter).
WithComplaintService(services.Complaint).
WithMCFService(services.MCF).
WithFleetStatusService(services.FleetStatus).
WithFlightInspectionService(services.FlightInspection).
WithBeforeFlightInspectionService(services.BeforeFlightInspection).
WithFlightPrepCheckService(services.FlightPrepCheck).
WithHelicopterFileService(services.HelicopterFile).
WithFlightInspectionFileChecklistService(services.FlightInspectionChecklist).
WithFileStorage(infra.FileStorage),
OtherPerson: handlers.NewOtherPersonHandler(services.OtherPerson),
Facility: handlers.NewFacilityHandler(services.Facility),
Base: handlers.NewBaseHandler(services.Base).WithFileManagerService(services.FileManager).WithFileStorage(infra.FileStorage),
Medicine: handlers.NewMedicineHandler(services.Medicine),
Vocation: handlers.NewVocationHandler(services.Vocation),
Opc: handlers.NewOpcHandler(services.Opc),
ForcesPresent: handlers.NewForcesPresentHandler(services.ForcesPresent),
Hospital: handlers.NewHospitalHandler(services.Hospital),
HealthInsuranceCompanies: handlers.NewHealthInsuranceCompaniesHandler(services.HealthInsuranceCompanies),
FederalState: handlers.NewFederalStateHandler(services.FederalState),
ICAO: handlers.NewICAOHandler(services.ICAO),
Land: handlers.NewLandHandler(services.Land),
MasterSettings: handlers.NewMasterSettingsHandler(services.MasterSettings, services.FileManager, infra.FileStorage),
Branding: handlers.NewBrandingHandler(services.MasterSettings, services.FileManager, infra.FileStorage),
FileManagerFolder: handlers.NewFileManagerFolderHandler(services.FileManager, infra.FileStorage),
FileManagerFile: handlers.NewFileManagerFileHandler(services.FileManager, infra.FileStorage).
WithHelicopterFileService(services.HelicopterFile).
WithAircraftService(services.Helicopter).
WithBaseService(services.Base).
WithDULService(services.DUL).
WithAuthService(services.Auth).
WithTakeoverService(services.Takeover).
WithMissionService(services.Mission).
WithUserService(services.User).
WithFleetStatusFileService(service.NewFleetStatusFileService(services.FileManager)).
WithHelicopterFileEvents(infra.HelicopterFileHub).
WithWOPIService(services.WOPI),
FileManagerAttachment: handlers.NewFileManagerAttachmentHandler(services.FileManager),
FileManagerEvents: handlers.NewFileManagerEventsHandler(infra.FileStatusHub),
HelicopterFileEvents: handlers.NewHelicopterFileEventsHandler(infra.HelicopterFileHub),
DutyRoster: handlers.NewDutyRosterHandler(services.DutyRoster).WithAudit(services.Audit).WithFlightService(services.Flight),
AirRescuerChecklist: handlers.NewAirRescuerChecklistHandler(services.AirRescuerChecklist),
InsurancePatientData: handlers.NewInsurancePatientDataHandler(services.InsurancePatientData),
PatientData: handlers.NewPatientDataHandler(services.PatientData),
DUL: handlers.NewDULHandler(services.DUL).WithBaseService(services.Base).WithContactService(services.Contact).WithFileManagerService(services.FileManager).WithFileStorage(infra.FileStorage),
FleetStatus: fleetStatusHandler,
HEMSOperationalData: handlers.NewHEMSOperationalDataHandler(services.HEMSOperationalData).WithFileManagerService(services.FileManager),
HEMSOperationCategory: handlers.NewHEMSOperationCategoryHandler(services.HEMSOperationCategory),
HEMSOperation: handlers.NewHEMSOperationHandler(services.HEMSOperation),
Complaint: handlers.NewComplaintHandler(services.Complaint).WithHelicopterService(services.Helicopter).WithMCFService(services.MCF).WithHistoryService(services.FleetHistory).WithAOGRecalculator(services.FleetStatus).WithActionSignoffService(services.ActionSignoff),
EASARelease: handlers.NewEASAReleaseHandler(services.EASARelease).WithComplaintActionChecker(services.Complaint).WithActionSignoffChecker(services.ActionSignoff).WithHistoryService(services.FleetHistory).WithContactResolver(services.Contact).WithAOGRecalculator(services.FleetStatus),
ActionSignoff: handlers.NewActionSignoffHandler(services.ActionSignoff).WithComplaintService(services.Complaint),
MCF: handlers.NewMCFHandler(services.MCF).WithHistoryService(services.FleetHistory),
}, nil
}
func initializeRealtimeComponents(
ctx context.Context,
repos *Repositories,
services *Services,
infra *Infrastructure,
) error {
if repos == nil || services == nil || infra == nil {
return errors.New("repositories, services, and infrastructure are required")
}
userNameCache := userctx.NewUserNameCache(repos.User, 5*time.Minute)
handlers.WireUserNameGetter(userNameCache)
startFileManagerAutoPurgeLoop(ctx, services.FileManager, infra.FileTrashPolicy, infra.Logger)
startFileManagerLifecycleCleanupLoop(ctx, services.FileManager, infra.FileLifecycle, infra.Logger)
startHelicopterFileAttachmentReconcileLoop(ctx, services.HelicopterFile, infra.Logger)
startFleetStatusAOGRecomputeLoop(ctx, services.FleetStatus, infra.Logger)
fileStatusPoller := filemanagerrealtime.NewStatusPoller(infra.DB, infra.FileStatusHub, infra.Logger, filemanagerrealtime.StatusPollerConfig{
// Keep the SSE ready event responsive; this poller is the bridge between
// the async file processor and the live file.updated stream.
PollInterval: 250 * time.Millisecond,
BatchSize: 100,
OnPublished: func(ctx context.Context, event filemanagerrealtime.FileStatusEvent) error {
if filemanager.NormalizeFileStatus(event.Status) != filemanager.FileStatusReady {
return nil
}
if services == nil || services.HelicopterFile == nil {
return nil
}
finalized, err := services.HelicopterFile.FinalizePendingAttachmentsBySourceFileUUID(ctx, event.FileUUID, 50)
if err != nil {
return err
}
if finalized > 0 && infra.Logger != nil {
infra.Logger.Info("helicopter file attachments finalized from file ready event", "file_uuid", event.FileUUID, "finalized", finalized)
}
return nil
},
})
go func() {
if err := fileStatusPoller.Run(ctx); err != nil && ctx.Err() == nil {
infra.Logger.Error("file status poller stopped unexpectedly", "error", err)
}
}()
return nil
}
func buildHTTPServer(appCfg config.AppConfig, services *Services, handlersSet *Handlers, infra *Infrastructure) (*fiber.App, string, error) {
if services == nil || handlersSet == nil || infra == nil {
return nil, "", errors.New("services, handlers, and infra are required")
}
httpCfg := appCfg.HTTP
app := fiber.New(fiber.Config{
BodyLimit: httpCfg.BodyLimit,
ReadTimeout: httpCfg.ReadTimeout,
WriteTimeout: httpCfg.WriteTimeout,
IdleTimeout: httpCfg.IdleTimeout,
})
api.UseDefaultMiddlewares(app, httpCfg)
envCfg := config.LoadEnvConfig()
hasJSONSAML := appCfg.SAML.CertFile != "" ||
appCfg.SAML.KeyFile != "" ||
appCfg.SAML.MetadataFile != "" ||
appCfg.SAML.RootURL != "" ||
appCfg.SAML.FrontendURL != "" ||
appCfg.SAML.CookieDomain != "" ||
appCfg.SAML.SessionTTLHr > 0
if appCfg.SAML.CertFile != "" {
envCfg.SAMLCertFile = appCfg.SAML.CertFile
}
if appCfg.SAML.KeyFile != "" {
envCfg.SAMLKeyFile = appCfg.SAML.KeyFile
}
if appCfg.SAML.MetadataFile != "" {
envCfg.SAMLMetadataFile = appCfg.SAML.MetadataFile
}
if appCfg.SAML.RootURL != "" {
envCfg.SAMLRootURL = appCfg.SAML.RootURL
}
if appCfg.SAML.FrontendURL != "" {
envCfg.FrontendURL = appCfg.SAML.FrontendURL
}
if appCfg.SAML.CookieDomain != "" {
envCfg.CookieDomain = appCfg.SAML.CookieDomain
}
if hasJSONSAML {
envCfg.CookieSecure = appCfg.SAML.CookieSecure
}
if appCfg.SAML.SessionTTLHr > 0 {
envCfg.SessionTTLHour = appCfg.SAML.SessionTTLHr
}
// Microsoft auth mode selector. Default "oauth" uses the OIDC/OAuth2 authorization
// code flow (Microsoft refresh tokens are stored in DB and validated on
// /api/v1/auth/refresh). Set MICROSOFT_AUTH_MODE=saml to fall back to the legacy
// SAML flow (kept intact for rollback).
microsoftAuthMode := strings.ToLower(strings.TrimSpace(os.Getenv("MICROSOFT_AUTH_MODE")))
if microsoftAuthMode == "" {
microsoftAuthMode = "oauth"
}
var samlHandler *samlauth.Handler
if microsoftAuthMode == "saml" {
if err := samlauth.InitSessionStoreDB(infra.DB); err == nil {
if err := samlauth.InitSAML(envCfg); err == nil {
samlHandler = samlauth.NewHandler(envCfg, services.Auth)
}
}
}
slog.Info("microsoft auth mode configured", slog.String("mode", microsoftAuthMode), slog.Bool("saml_active", samlHandler != nil))
api.RegisterRoutes(app, api.RouteDependencies{
Handlers: api.RouteHandlers{
User: handlersSet.User,
Contact: handlersSet.Contact,
Health: handlersSet.Health,
BuildInfo: handlersSet.BuildInfo,
Auth: handlersSet.Auth,
Role: handlersSet.Role,
Helicopter: handlersSet.Helicopter,
HelicopterUsage: handlersSet.HelicopterUsage,
HelicopterFile: handlersSet.HelicopterFile,
Flight: handlersSet.Flight,
FlightData: handlersSet.FlightData,
Mission: handlersSet.Mission,
FMReport: handlersSet.FMReport,
ReserveAc: handlersSet.ReserveAc,
Takeover: handlersSet.Takeover,
OtherPerson: handlersSet.OtherPerson,
Facility: handlersSet.Facility,
Base: handlersSet.Base,
Medicine: handlersSet.Medicine,
Vocation: handlersSet.Vocation,
Opc: handlersSet.Opc,
ForcesPresent: handlersSet.ForcesPresent,
Hospital: handlersSet.Hospital,
HealthInsuranceCompanies: handlersSet.HealthInsuranceCompanies,
FederalState: handlersSet.FederalState,
ICAO: handlersSet.ICAO,
Land: handlersSet.Land,
MasterSettings: handlersSet.MasterSettings,
Branding: handlersSet.Branding,
FileManagerFolder: handlersSet.FileManagerFolder,
FileManagerFile: handlersSet.FileManagerFile,
FileManagerAttachment: handlersSet.FileManagerAttachment,
FileManagerEvents: handlersSet.FileManagerEvents,
HelicopterFileEvents: handlersSet.HelicopterFileEvents,
DutyRoster: handlersSet.DutyRoster,
AirRescuerChecklist: handlersSet.AirRescuerChecklist,
InsurancePatientData: handlersSet.InsurancePatientData,
PatientData: handlersSet.PatientData,
DUL: handlersSet.DUL,
FleetStatus: handlersSet.FleetStatus,
HEMSOperationalData: handlersSet.HEMSOperationalData,
HEMSOperationCategory: handlersSet.HEMSOperationCategory,
HEMSOperation: handlersSet.HEMSOperation,
Complaint: handlersSet.Complaint,
EASARelease: handlersSet.EASARelease,
ActionSignoff: handlersSet.ActionSignoff,
MCF: handlersSet.MCF,
SAMLAuth: samlHandler,
},
Services: api.RouteServices{
Auth: services.Auth,
Audit: services.Audit,
WOPI: services.WOPI,
},
})
addr := ":8080"
if appCfg.HTTPPort > 0 {
addr = fmt.Sprintf(":%d", appCfg.HTTPPort)
}
return app, addr, nil
}

View File

@@ -0,0 +1,129 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"wucher/internal/config"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/repository/mysql"
s3repo "wucher/internal/repository/s3"
)
type backfillReport struct {
Total int `json:"total"`
ActiveFiles int `json:"active_files"`
ProbableOrphans int `json:"probable_orphan_files"`
MissingS3Key int `json:"missing_s3_key"`
InvalidReference int `json:"invalid_references"`
Skipped int `json:"skipped"`
DryRun bool `json:"dry_run"`
}
func main() {
ctx := context.Background()
cfg, err := config.LoadFromFile(configFilePath())
if err != nil {
fmt.Fprintf(os.Stderr, "load config: %v\n", err)
os.Exit(1)
}
db, err := mysql.Connect(cfg.MySQL)
if err != nil {
fmt.Fprintf(os.Stderr, "connect db: %v\n", err)
os.Exit(1)
}
fileRepo := mysql.NewFileManagerFileRepository(db)
lifecycleRepo, ok := any(fileRepo).(filemanager.FileLifecycleRepository)
if !ok {
fmt.Fprintln(os.Stderr, "file repository does not support lifecycle operations")
os.Exit(1)
}
dryRun := true
if raw := os.Getenv("BACKFILL_DRY_RUN"); raw != "" && (raw == "false" || raw == "0") {
dryRun = false
}
var storage filemanager.ObjectStorage
if cfg.FileStorage.Bucket != "" && cfg.FileStorage.Region != "" {
objStorage, s3Err := s3repo.NewFileObjectStorage(ctx, cfg.FileStorage)
if s3Err == nil {
storage = objStorage
}
}
rows := make([]filemanager.File, 0)
if err := db.WithContext(ctx).Model(&filemanager.File{}).Find(&rows).Error; err != nil {
fmt.Fprintf(os.Stderr, "query files: %v\n", err)
os.Exit(1)
}
report := backfillReport{DryRun: dryRun}
now := time.Now().UTC()
for i := range rows {
row := rows[i]
report.Total++
if row.DeletedAt != nil {
report.Skipped++
continue
}
referenced, refErr := lifecycleRepo.IsFileReferenced(ctx, row.ID)
if refErr != nil {
report.InvalidReference++
report.Skipped++
continue
}
if storage != nil {
if _, headErr := storage.HeadObject(ctx, row.ObjectKey); headErr != nil {
report.MissingS3Key++
}
}
if referenced {
report.ActiveFiles++
if !dryRun {
_ = lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{
ID: row.ID,
Status: filemanager.FileLifecycleStatusActive,
AttachedAt: &now,
ClearOrphanedAt: true,
})
}
continue
}
report.ProbableOrphans++
if !dryRun {
_ = lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{
ID: row.ID,
Status: filemanager.FileLifecycleStatusOrphanPending,
OrphanedAt: &now,
})
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(report)
}
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
}

79
cmd/healthcheck/main.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"bufio"
"net"
"os"
"strings"
"time"
)
const (
defaultTarget = "http://127.0.0.1:8080/health"
timeout = 2 * time.Second
)
func main() {
target := strings.TrimSpace(os.Getenv("HEALTHCHECK_URL"))
if target == "" {
target = defaultTarget
}
if len(os.Args) > 1 {
if arg := strings.TrimSpace(os.Args[1]); arg != "" {
target = arg
}
}
addr, path, ok := splitTarget(target)
if !ok {
os.Exit(1)
}
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
os.Exit(1)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "GET " + path + " HTTP/1.1\r\nHost: " + addr + "\r\nConnection: close\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
os.Exit(1)
}
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
os.Exit(1)
}
fields := strings.Fields(line)
if len(fields) < 2 {
os.Exit(1)
}
code := fields[1]
if len(code) != 3 || (code[0] != '2' && code[0] != '3') {
os.Exit(1)
}
}
func splitTarget(target string) (string, string, bool) {
target = strings.TrimSpace(target)
if target == "" || strings.HasPrefix(target, "https://") {
return "", "", false
}
target = strings.TrimPrefix(target, "http://")
addr := target
path := "/"
if slash := strings.IndexByte(target, '/'); slash >= 0 {
addr = target[:slash]
path = target[slash:]
}
if addr == "" || !strings.Contains(addr, ":") {
return "", "", false
}
return addr, path, true
}

83
cmd/worker/file_worker.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"context"
"errors"
"os/signal"
"strings"
"syscall"
queueapp "wucher/internal/app/queueing"
appworker "wucher/internal/app/worker"
"wucher/internal/config"
"wucher/internal/queue"
mysqlrepo "wucher/internal/repository/mysql"
s3repo "wucher/internal/repository/s3"
"wucher/internal/resilience"
"wucher/internal/service"
"wucher/internal/shared/pkg/logger"
)
func runFileWorker(appCfg config.AppConfig) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
appLogger := logger.New(appCfg.Logging).With("component", "worker", "job", "file_processing")
resCfg := appCfg.Resilience
fileQueueCfg := appCfg.FileQueue
fileSQSCfg := appCfg.FileSQS
if err := config.ValidateDistinctQueueURLs(appCfg.SQS, fileSQSCfg); err != nil {
return err
}
if strings.TrimSpace(fileSQSCfg.QueueURL) == "" {
return errors.New("FILE_SQS_QUEUE_URL is required for file worker")
}
db, err := mysqlrepo.Connect(appCfg.MySQL)
if err != nil {
return err
}
fileRepo := mysqlrepo.NewFileManagerFileRepository(db)
sqsBreaker := resilience.NewExecutor("sqs", resCfg.SQS, appLogger, resilience.ClassifySQSError)
consumer, serializer, err := queueapp.NewFileProcessingConsumer(ctx, fileQueueCfg, fileSQSCfg, sqsBreaker)
if err != nil {
return err
}
idempotency := queueapp.NewIdempotencyStoreWithPrefix(db, fileQueueCfg.IdempotencyKeyPrefix)
useCaseDeps := service.FileProcessingUseCaseDependencies{
Logger: appLogger,
FileStatusEvents: fileRepo,
GotenbergURL: appCfg.Gotenberg.URL,
}
fileStorageCfg := appCfg.FileStorage
if fileStorageCfg.Bucket != "" && fileStorageCfg.Region != "" {
storage, err := s3repo.NewFileObjectStorage(ctx, fileStorageCfg)
if err != nil {
return err
}
useCaseDeps.Storage = storage
}
useCase := service.NewFileProcessingUseCase(fileRepo, useCaseDeps)
handler := queue.NewFileProcessingJobQueueHandler(useCase, service.IsFileProcessingPermanentError)
var dispatcher *appworker.FileProcessingOutboxDispatcher
if fileQueueCfg.OutboxEnabled {
rawProducer, err := queueapp.NewQueueProducer(ctx, fileSQSCfg, sqsBreaker, "outbox_dispatcher")
if err != nil {
return err
}
dispatcher = appworker.NewFileProcessingOutboxDispatcher(fileQueueCfg, fileRepo, rawProducer, appLogger)
}
runner := appworker.NewFileProcessingRunner(fileQueueCfg, consumer, serializer, handler, idempotency, dispatcher, appLogger)
appLogger.Info("file worker started",
"backend", "sqs",
"monitor_addr", fileQueueCfg.WorkerMonitorAddr,
"queue_url", fileSQSCfg.QueueURL,
"outbox_enabled", fileQueueCfg.OutboxEnabled,
)
if err := runner.Start(ctx); err != nil {
return err
}
appLogger.Info("file worker stopped")
return nil
}

99
cmd/worker/mail_worker.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"log/slog"
"os/signal"
"syscall"
queueapp "wucher/internal/app/queueing"
appworker "wucher/internal/app/worker"
"wucher/internal/config"
"wucher/internal/shared/pkg/logger"
mysqlrepo "wucher/internal/repository/mysql"
"wucher/internal/resilience"
"wucher/internal/service"
)
func runMailWorker(appCfg config.AppConfig) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
appLogger := logger.New(appCfg.Logging).With("component", "worker", "job", "email")
resCfg := appCfg.Resilience
emailCfg := appCfg.Email
queueCfg := appCfg.Queue
sqsCfg := appCfg.SQS
db, err := mysqlrepo.Connect(appCfg.MySQL)
if err != nil {
return err
}
authRepo := mysqlrepo.NewAuthRepository(db)
sqsBreaker := resilience.NewExecutor("sqs", resCfg.SQS, appLogger, resilience.ClassifySQSError)
consumer, serializer, err := queueapp.NewEmailConsumer(ctx, queueCfg, sqsCfg, sqsBreaker)
if err != nil {
return err
}
idempotency := queueapp.NewIdempotencyStore(db, queueCfg)
var dispatcher *appworker.OutboxDispatcher
if queueCfg.OutboxEnabled {
rawProducer, err := queueapp.NewQueueProducer(ctx, sqsCfg, sqsBreaker, "outbox_dispatcher")
if err != nil {
return err
}
dispatcher = appworker.NewOutboxDispatcher(queueCfg, authRepo, rawProducer, appLogger)
}
emailSender, err := buildEmailSender(ctx, emailCfg, appCfg.SES, resCfg, appLogger)
if err != nil {
return err
}
handler := service.NewEmailDeliveryHandler(emailSender, appLogger)
runner := appworker.NewRunner(queueCfg, consumer, serializer, handler, idempotency, dispatcher, appLogger)
appLogger.Info("worker started",
"backend", "sqs",
"email_provider", emailCfg.Provider,
"monitor_addr", queueCfg.WorkerMonitorAddr,
"outbox_enabled", queueCfg.OutboxEnabled,
)
if err := runner.Start(ctx); err != nil {
return err
}
appLogger.Info("worker stopped")
return nil
}
func buildEmailSender(
ctx context.Context,
emailCfg config.EmailDeliveryConfig,
sesCfg config.SESConfig,
resCfg config.ResilienceConfig,
appLogger *slog.Logger,
) (service.EmailSender, error) {
switch emailCfg.Provider {
case "", "ses":
sesBreaker := resilience.NewExecutor("ses", resCfg.SES, appLogger, resilience.ClassifySESError)
sender, err := service.NewSESSender(ctx, sesCfg)
if err != nil {
return nil, err
}
sender = sender.WithExecutor(sesBreaker)
sender = sender.WithMetricsComponent("email_worker")
if !sender.Configured() {
return nil, errors.New("ses is not configured")
}
return sender, nil
case "mock":
sender := service.NewMockEmailSender(emailCfg.MockDir)
if !sender.Configured() {
return nil, errors.New("mock email sender is not configured")
}
return sender, nil
default:
return nil, errors.New("unsupported email provider: " + emailCfg.Provider)
}
}

80
cmd/worker/main.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"strings"
"wucher/internal/config"
)
const (
workerJobEnvKey = "WORKER_JOB"
workerJobEmail = "email"
workerJobFileProcessing = "file_processing"
workerJobAliasMail = "mail"
workerJobAliasEmailWorker = "email_worker"
workerJobAliasFile = "file"
workerJobAliasFileWorker = "file_worker"
)
var (
workerRunFn = run
workerFatalFn = log.Fatal
runMailWorkerFn func(config.AppConfig) error = runMailWorker
runFileWorkerFn func(config.AppConfig) error = runFileWorker
)
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
}
func main() {
if err := workerRunFn(); err != nil {
workerFatalFn(err)
}
}
func run() error {
appCfg, err := config.LoadFromFile(configFilePath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
job := normalizeWorkerJob(os.Getenv(workerJobEnvKey))
switch job {
case workerJobEmail:
return runMailWorkerFn(appCfg)
case workerJobFileProcessing:
return runFileWorkerFn(appCfg)
default:
return errors.New("unsupported worker job: " + job)
}
}
func normalizeWorkerJob(raw string) string {
job := strings.ToLower(strings.TrimSpace(raw))
switch job {
case "", workerJobEmail, workerJobAliasMail, workerJobAliasEmailWorker:
return workerJobEmail
case workerJobFileProcessing, workerJobAliasFile, workerJobAliasFileWorker:
return workerJobFileProcessing
default:
return job
}
}

136
cmd/worker/main_test.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"wucher/internal/config"
"wucher/internal/shared/pkg/logger"
)
// writeTestConfig writes a minimal valid JSON config to a temp file and sets
// CONFIG_FILE so that run() can load it.
func writeTestConfig(t *testing.T) {
t.Helper()
f := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(f, []byte("{}"), 0o600); err != nil {
t.Fatalf("writeTestConfig: %v", err)
}
t.Setenv("CONFIG_FILE", f)
}
func TestMain_CallsRun(t *testing.T) {
origRun := workerRunFn
t.Cleanup(func() { workerRunFn = origRun })
runCalled := false
workerRunFn = func() error {
runCalled = true
return nil
}
main()
if !runCalled {
t.Fatalf("expected worker run function to be called")
}
}
func TestRun_DefaultsToMailWorker(t *testing.T) {
origMail := runMailWorkerFn
origFile := runFileWorkerFn
t.Cleanup(func() {
runMailWorkerFn = origMail
runFileWorkerFn = origFile
})
writeTestConfig(t)
t.Setenv(workerJobEnvKey, "")
mailCalled := false
fileCalled := false
runMailWorkerFn = func(config.AppConfig) error {
mailCalled = true
return nil
}
runFileWorkerFn = func(config.AppConfig) error {
fileCalled = true
return nil
}
if err := run(); err != nil {
t.Fatalf("run: %v", err)
}
if !mailCalled {
t.Fatalf("expected mail worker to be called")
}
if fileCalled {
t.Fatalf("expected file worker not to be called")
}
}
func TestRun_UsesFileWorkerWhenConfigured(t *testing.T) {
origMail := runMailWorkerFn
origFile := runFileWorkerFn
t.Cleanup(func() {
runMailWorkerFn = origMail
runFileWorkerFn = origFile
})
writeTestConfig(t)
t.Setenv(workerJobEnvKey, workerJobFileProcessing)
mailCalled := false
fileCalled := false
runMailWorkerFn = func(config.AppConfig) error {
mailCalled = true
return nil
}
runFileWorkerFn = func(config.AppConfig) error {
fileCalled = true
return nil
}
if err := run(); err != nil {
t.Fatalf("run: %v", err)
}
if !fileCalled {
t.Fatalf("expected file worker to be called")
}
if mailCalled {
t.Fatalf("expected mail worker not to be called")
}
}
func TestRun_ReturnsErrorOnUnsupportedWorkerJob(t *testing.T) {
writeTestConfig(t)
t.Setenv(workerJobEnvKey, "unknown_worker")
err := run()
if err == nil || !strings.Contains(err.Error(), "unsupported worker job") {
t.Fatalf("expected unsupported worker job error, got %v", err)
}
}
func TestBuildEmailSenderMock(t *testing.T) {
sender, err := buildEmailSender(context.Background(), config.EmailDeliveryConfig{
Provider: "mock",
MockDir: t.TempDir(),
}, config.SESConfig{}, config.ResilienceConfig{}, logger.NewDiscard())
if err != nil {
t.Fatalf("buildEmailSender: %v", err)
}
if sender == nil {
t.Fatalf("expected sender")
}
}
func TestBuildEmailSenderUnsupportedProvider(t *testing.T) {
_, err := buildEmailSender(context.Background(), config.EmailDeliveryConfig{
Provider: "unknown",
}, config.SESConfig{}, config.ResilienceConfig{}, logger.NewDiscard())
if err == nil || !strings.Contains(err.Error(), "unsupported email provider") {
t.Fatalf("expected unsupported provider error, got %v", err)
}
}