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:
}
}
}()
}