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

52
.air.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/api"
cmd = "go build -o ./tmp/api ./cmd/api"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
.git
.gitignore
.github/
.circleci/
.gitlab-ci.yml
.idea/
.vscode/
.DS_Store
# Never send local secrets or editor/dev-only files into the build context.
.env
.env.*
!.env.example
.air.toml
# Test and coverage artifacts are not needed for production image builds.
**/*_test.go
coverage.out
coverage.html
*.cover
*.log
# Exclude docs and directories not needed by the production Dockerfile.
docs/*.md
docs/collections/
docs/swagger.json
docs/swagger.yaml
docs/docs_test.go
deployments/
migrations/
tmp/
dist/
bin/

248
.env.local.example Normal file
View File

@@ -0,0 +1,248 @@
# Copy this to .env.local and fill in your values.
# Never commit .env.local.
# App
APP_ENV=local
# HTTP
HTTP_PORT=8080
HTTP_BODY_LIMIT=4194304
HTTP_READ_TIMEOUT=10s
HTTP_WRITE_TIMEOUT=15s
HTTP_IDLE_TIMEOUT=60s
HTTP_CORS_ALLOW_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173
HTTP_CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD
HTTP_CORS_ALLOW_HEADERS=Origin,Content-Type,Accept,Authorization,X-Requested-With,X-Pin-Verification-Token,X-Device-Type
HTTP_CORS_EXPOSE_HEADERS=X-Request-ID
HTTP_CORS_ALLOW_CREDENTIALS=true
HTTP_CORS_MAX_AGE=300
HTTP_REQUEST_TIMEOUT=30s
HTTP_REQUEST_TIMEOUT_OVERRIDES=
HTTP_SHUTDOWN_TIMEOUT=30s
HTTP_RATE_LIMIT_MAX=120
HTTP_RATE_LIMIT_WINDOW=1m
HTTP_SECURITY_HSTS_MAX_AGE=0
# MySQL
MYSQL_DSN="root:password@tcp(localhost:3306)/fm_dev?parseTime=true"
MYSQL_MAX_OPEN_CONNS=25
MYSQL_MAX_IDLE_CONNS=10
MYSQL_CONN_MAX_LIFETIME=5m
MYSQL_CONN_MAX_IDLE_TIME=2m
# Logging
LOG_LEVEL=debug
LOG_FORMAT=json
# Audit
AUDIT_LOG_QUEUE_SIZE=1024
AUDIT_LOG_WORKERS=2
# Email
EMAIL_PROVIDER=mock
EMAIL_MOCK_DIR=.local/emails
# Circuit Breaker (default policy)
CB_DEFAULT_ENABLED=true
CB_DEFAULT_MAX_REQUESTS=1
CB_DEFAULT_INTERVAL=1m
CB_DEFAULT_BUCKET_PERIOD=10s
CB_DEFAULT_TIMEOUT=30s
CB_DEFAULT_MIN_REQUESTS=5
CB_DEFAULT_FAILURE_RATIO=0.6
CB_DEFAULT_CONSECUTIVE_FAILURES=5
CB_MICROSOFT_SSO_ENABLED=true
CB_MICROSOFT_SSO_MAX_REQUESTS=1
CB_MICROSOFT_SSO_INTERVAL=1m
CB_MICROSOFT_SSO_BUCKET_PERIOD=10s
CB_MICROSOFT_SSO_TIMEOUT=30s
CB_MICROSOFT_SSO_MIN_REQUESTS=5
CB_MICROSOFT_SSO_FAILURE_RATIO=0.6
CB_MICROSOFT_SSO_CONSECUTIVE_FAILURES=5
CB_SES_ENABLED=true
CB_SES_MAX_REQUESTS=1
CB_SES_INTERVAL=1m
CB_SES_BUCKET_PERIOD=10s
CB_SES_TIMEOUT=30s
CB_SES_MIN_REQUESTS=5
CB_SES_FAILURE_RATIO=0.6
CB_SES_CONSECUTIVE_FAILURES=5
CB_SQS_ENABLED=true
CB_SQS_MAX_REQUESTS=1
CB_SQS_INTERVAL=1m
CB_SQS_BUCKET_PERIOD=10s
CB_SQS_TIMEOUT=30s
CB_SQS_MIN_REQUESTS=5
CB_SQS_FAILURE_RATIO=0.6
CB_SQS_CONSECUTIVE_FAILURES=5
# Auth
AUTH_PASSWORD_PEPPER=change-me-local-pepper
AUTH_IP_ENCRYPTION_KEY=change-me-local-ip-key
AUTH_DEFAULT_ROLE=user
AUTH_DISABLE_REGISTER=false
AUTH_HASH_TIME=1
AUTH_HASH_MEMORY_KB=65536
AUTH_HASH_THREADS=4
AUTH_HASH_KEY_LEN=32
AUTH_EMAIL_VERIFY_TTL=24h
AUTH_EMAIL_VERIFY_URL=http://localhost:3000/verify-email
AUTH_INVITE_TTL=24h
AUTH_SET_PASSWORD_URL=http://localhost:3000/set-password
AUTH_PASSWORD_RESET_TTL=20m
AUTH_PASSWORD_RESET_URL=http://localhost:3000/reset-password
AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN=2m
AUTH_FORGOT_PASSWORD_MIN_DURATION=150ms
AUTH_FORGOT_PASSWORD_IP_RATE_MAX=10
AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW=15m
AUTH_SECURITY_PIN_RESET_TTL=20m
AUTH_SECURITY_PIN_RESET_URL=http://localhost:3000/reset-pin
AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN=2m
AUTH_FORGOT_SECURITY_PIN_MIN_DURATION=150ms
AUTH_SECURITY_PIN_MAX_ATTEMPTS=5
AUTH_SECURITY_PIN_LOCK_DURATION=0s
AUTH_SECURITY_PIN_ACTION_TOKEN_TTL=5m
AUTH_JWT_COOKIE_DOMAIN=.mybit.co.id
# JWT
AUTH_JWT_ACCESS_SECRET=change-me-local-access-secret
AUTH_JWT_REFRESH_SECRET=change-me-local-refresh-secret
AUTH_JWT_ACCESS_TTL=10m
AUTH_JWT_REFRESH_TTL=1h
AUTH_JWT_REFRESH_TOTP_TTL=1h
AUTH_JWT_COOKIE_SECURE=false
AUTH_JWT_COOKIE_SAMESITE=Lax
AUTH_JWT_ACCESS_COOKIE=wucher_at
AUTH_JWT_REFRESH_COOKIE=wucher_rt
# TOTP
AUTH_TOTP_CHALLENGE_TTL=5m
TOTP_ISSUER=Wucher
TOTP_SECRET_KEY=change-me-local-totp-secret
# SSO
AUTH_SSO_STATE_TTL=10m
AUTH_SSO_SUCCESS_REDIRECT=http://localhost:3000/sso/callback
# WebAuthn
AUTH_WEBAUTHN_RP_ID=localhost
AUTH_WEBAUTHN_RP_DISPLAY_NAME=Wucher
AUTH_WEBAUTHN_RP_ORIGINS=http://localhost:3000
AUTH_WEBAUTHN_CHALLENGE_TTL=5m
# Microsoft SSO
MS_ENTRA_TENANT_ID=
MS_ENTRA_CLIENT_ID=
MS_ENTRA_CLIENT_SECRET=
MS_ENTRA_REDIRECT_URL=http://localhost:8080/v1/auth/sso/microsoft/callback
MS_ENTRA_AUTHORITY=https://login.microsoftonline.com/
MS_ENTRA_SCOPES=openid,profile,email,offline_access,User.Read
# Queue
QUEUE_MESSAGE_SCHEMA_VERSION=v2
QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK=true
QUEUE_IDEMPOTENCY_KEY_PREFIX=queue:idempotency:email:
QUEUE_IDEMPOTENCY_TTL=24h
QUEUE_PROCESSING_TTL=15m
QUEUE_WORKER_CONCURRENCY=4
QUEUE_WORKER_MAX_BATCH_SIZE=10
QUEUE_WORKER_PROCESS_TIMEOUT=30s
QUEUE_WORKER_SHUTDOWN_TIMEOUT=30s
QUEUE_WORKER_BACKOFF_MIN=1s
QUEUE_WORKER_BACKOFF_MAX=5m
QUEUE_WORKER_PERMANENT_FAILURE_DELAY=30s
QUEUE_WORKER_MONITOR_ADDR=127.0.0.1:9090
QUEUE_WORKER_HEALTH_ERROR_THRESHOLD=3
QUEUE_WORKER_DEPTH_POLL_INTERVAL=30s
QUEUE_OUTBOX_ENABLED=true
QUEUE_OUTBOX_POLL_INTERVAL=1s
QUEUE_OUTBOX_BATCH_SIZE=10
QUEUE_OUTBOX_DISPATCH_TIMEOUT=10s
QUEUE_OUTBOX_LOCK_TTL=1m
QUEUE_OUTBOX_MAX_ATTEMPTS=20
# File Queue
FILE_QUEUE_MESSAGE_SCHEMA_VERSION=v1
FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX=queue:idempotency:file:
FILE_QUEUE_IDEMPOTENCY_TTL=24h
FILE_QUEUE_PROCESSING_TTL=15m
FILE_QUEUE_WORKER_CONCURRENCY=4
FILE_QUEUE_WORKER_MAX_BATCH_SIZE=10
FILE_QUEUE_WORKER_PROCESS_TIMEOUT=30s
FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT=30s
FILE_QUEUE_WORKER_BACKOFF_MIN=1s
FILE_QUEUE_WORKER_BACKOFF_MAX=5m
FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY=30s
FILE_QUEUE_WORKER_MONITOR_ADDR=127.0.0.1:9091
FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD=3
FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL=30s
FILE_QUEUE_OUTBOX_ENABLED=true
FILE_QUEUE_OUTBOX_POLL_INTERVAL=1s
FILE_QUEUE_OUTBOX_BATCH_SIZE=10
FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT=10s
FILE_QUEUE_OUTBOX_LOCK_TTL=1m
FILE_QUEUE_OUTBOX_MAX_ATTEMPTS=20
# AWS
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
# SQS (local uses localstack or similar)
SQS_ENDPOINT=http://localhost:4566
SQS_ALLOW_INSECURE_ENDPOINT=true
SQS_QUEUE_URL=http://localhost:4566/000000000000/fm-email-queue
SQS_QUEUE_TYPE=standard
SQS_MESSAGE_GROUP_ID=email
SQS_MAX_MESSAGES=10
SQS_WAIT_TIME_SECONDS=20
SQS_VISIBILITY_TIMEOUT=60s
# SQS (file queue, local uses localstack or similar)
FILE_SQS_REGION=us-east-1
FILE_SQS_ENDPOINT=http://localhost:4566
FILE_SQS_ALLOW_INSECURE_ENDPOINT=true
FILE_SQS_QUEUE_URL=http://localhost:4566/000000000000/fm-file-queue
FILE_SQS_QUEUE_TYPE=standard
FILE_SQS_MESSAGE_GROUP_ID=file-processing
FILE_SQS_MAX_MESSAGES=10
FILE_SQS_WAIT_TIME_SECONDS=20
FILE_SQS_VISIBILITY_TIMEOUT=60s
# SES (local uses localstack or similar)
SES_REGION=us-east-1
SES_FROM=noreply@localhost
SES_CONFIGURATION_SET=
SES_SEND_TIMEOUT=10s
SES_ENDPOINT=http://localhost:4566
SES_ALLOW_INSECURE_ENDPOINT=true
# File Storage (local uses localstack or similar)
FILE_STORAGE_PROVIDER=s3
FILE_S3_BUCKET=fm-local
FILE_S3_REGION=us-east-1
FILE_S3_ENDPOINT=http://localhost:4566
FILE_S3_FORCE_PATH_STYLE=true
FILE_S3_ALLOW_INSECURE_ENDPOINT=true
FILE_S3_AUTO_CREATE_BUCKET=true
FILE_OBJECT_KEY_PREFIX=fm/objects
FILE_S3_PRESIGN_PUT_TTL=15m
FILE_S3_PRESIGN_GET_TTL=15m
FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES=4194304
FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES=image/png,image/jpeg,application/pdf,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel
FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS=png,jpg,jpeg,pdf,xlsx,xls
FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE=false
FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH=255
FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED=true
FILE_MANAGER_TRASH_RETENTION=720h
FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL=1h
FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE=200
ENABLE_S3_TAGGING=true
ENABLE_FILE_CLEANUP_JOB=false
TEMP_FILE_TTL_DAYS=7
ORPHAN_GRACE_PERIOD_DAYS=30
FILE_CLEANUP_BATCH_SIZE=100
FILE_CLEANUP_DRY_RUN=true

62
.gitignore vendored Normal file
View File

@@ -0,0 +1,62 @@
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
*.prof
# Build
/bin/
/build/
/dist/
/tmp/
# Coverage
coverage.out
coverage.txt
# Go workspace
/vendor/
# IDE/editor
.vscode/
.idea/
*.swp
*.swo
*.swn
*.tmp
# OS
.DS_Store
Thumbs.db
# Env files
.env
.env.*
!.env.local.example
# Generated configs
*.generated.json
# Logs
*.log
# Docker
**/.docker/
# GoFiber (optional)
# (No special ignores required beyond Go build artifacts)
# Claude Config
.claude/
# Codex Config
.codex
# Graphify outputs
**/graphify-out/
.graphify-venv/
AGENTS.md

16
.releaserc Normal file
View File

@@ -0,0 +1,16 @@
{
"branches": [
"main",
{ "name": "staging", "prerelease": "rc" }
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@saithodev/semantic-release-gitea",
{
"giteaUrl": "https://repo.mybit.center"
}
]
]
}

109
Dockerfile Normal file
View File

@@ -0,0 +1,109 @@
# syntax=docker/dockerfile:1.7
# Override these in CI with immutable digests when you are ready to pin them fully.
ARG GO_IMAGE=golang:1.26.2-alpine3.22
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot
FROM ${GO_IMAGE} AS build-base
WORKDIR /src
ENV CGO_ENABLED=0 \
GOOS=linux \
GOFLAGS=-buildvcs=false
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
go mod download && go mod verify
FROM build-base AS healthcheck-build
COPY cmd/healthcheck/ ./cmd/healthcheck/
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
go build \
-trimpath \
-ldflags="-s -w" \
-o /out/healthcheck \
./cmd/healthcheck
FROM build-base AS worker-build
COPY cmd/worker/ ./cmd/worker/
COPY internal/ ./internal/
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
go build \
-trimpath \
-ldflags="-s -w" \
-o /out/wucher-worker \
./cmd/worker
RUN mkdir -p /out/.local/emails && touch /out/.local/emails/.keep
FROM build-base AS file-worker-build
COPY cmd/worker/ ./cmd/worker/
COPY internal/ ./internal/
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
go build \
-trimpath \
-ldflags="-s -w" \
-o /out/wucher-file-worker \
./cmd/worker
FROM build-base AS api-build
ARG VERSION=unknown
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
COPY cmd/api/ ./cmd/api/
COPY internal/ ./internal/
COPY docs/docs.go ./docs/docs.go
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
go build \
-trimpath \
-ldflags="-s -w -X 'wucher/internal/buildinfo.Version=${VERSION}' -X 'wucher/internal/buildinfo.Commit=${COMMIT}' -X 'wucher/internal/buildinfo.BuildTime=${BUILD_TIME}'" \
-o /out/wucher-api \
./cmd/api
FROM ${RUNTIME_IMAGE} AS runtime-base
WORKDIR /app
FROM runtime-base AS worker
COPY --from=healthcheck-build --chown=nonroot:nonroot /out/healthcheck /app/healthcheck
COPY --from=worker-build --chown=nonroot:nonroot /out/wucher-worker /app/wucher-worker
COPY --from=worker-build --chown=nonroot:nonroot /out/.local/emails/ /app/.local/emails/
ENV WORKER_JOB=email
USER nonroot:nonroot
ENTRYPOINT ["/app/wucher-worker"]
FROM runtime-base AS file-worker
COPY --from=healthcheck-build --chown=nonroot:nonroot /out/healthcheck /app/healthcheck
COPY --from=file-worker-build --chown=nonroot:nonroot /out/wucher-file-worker /app/wucher-file-worker
ENV WORKER_JOB=file_processing
USER nonroot:nonroot
ENTRYPOINT ["/app/wucher-file-worker"]
FROM runtime-base AS api
COPY --from=healthcheck-build --chown=nonroot:nonroot /out/healthcheck /app/healthcheck
COPY --from=api-build --chown=nonroot:nonroot /out/wucher-api /app/wucher-api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/wucher-api"]

107
Makefile Normal file
View File

@@ -0,0 +1,107 @@
GO ?= go
AIR ?= air
SWAG ?= swag
GOOSE ?= goose
GOOSE_DIR ?= migrations/goose
MIGRATE_APPLY_FROM ?= 20260414
.PHONY: air swag test worker file-worker migrate-status migrate-up migrate-up-to migrate-apply migrate-reapply hospital-apply
air:
@command -v $(AIR) >/dev/null 2>&1 || { \
echo "air not found. Install with: go install github.com/air-verse/air@latest"; \
exit 1; \
}
$(AIR) -c .air.toml
swag:
@command -v $(SWAG) >/dev/null 2>&1 || { \
echo "swag not found. Install with: go install github.com/swaggo/swag/cmd/swag@latest"; \
exit 1; \
}
GOCACHE=$${GOCACHE:-/tmp/go-cache} $(SWAG) init -g cmd/api/main.go -o docs --parseDependency --parseInternal
test:
$(GO) test ./... -coverprofile=coverage.out
$(GO) tool cover -func=coverage.out
worker:
WORKER_JOB=email $(GO) run ./cmd/worker
file-worker:
WORKER_JOB=file_processing $(GO) run ./cmd/worker
migrate-status:
@command -v $(GOOSE) >/dev/null 2>&1 || { \
echo "goose not found. Install with: go install github.com/pressly/goose/v3/cmd/goose@latest"; \
exit 1; \
}
@MYSQL_DSN_VAL="$${MYSQL_DSN:-$$(grep -hE '^MYSQL_DSN=' .env .env.local 2>/dev/null | tail -n1 | cut -d= -f2-)}"; \
MYSQL_DSN_VAL="$${MYSQL_DSN_VAL#\"}"; MYSQL_DSN_VAL="$${MYSQL_DSN_VAL%\"}"; \
test -n "$$MYSQL_DSN_VAL" || { echo "MYSQL_DSN is required (set env or define in .env/.env.local)"; exit 1; }; \
$(GOOSE) -dir $(GOOSE_DIR) mysql "$$MYSQL_DSN_VAL" status
migrate-up:
@command -v $(GOOSE) >/dev/null 2>&1 || { \
echo "goose not found. Install with: go install github.com/pressly/goose/v3/cmd/goose@latest"; \
exit 1; \
}
@MYSQL_DSN_VAL="$${MYSQL_DSN:-$$(grep -hE '^MYSQL_DSN=' .env .env.local 2>/dev/null | tail -n1 | cut -d= -f2-)}"; \
MYSQL_DSN_VAL="$${MYSQL_DSN_VAL#\"}"; MYSQL_DSN_VAL="$${MYSQL_DSN_VAL%\"}"; \
test -n "$$MYSQL_DSN_VAL" || { echo "MYSQL_DSN is required (set env or define in .env/.env.local)"; exit 1; }; \
$(GOOSE) -dir $(GOOSE_DIR) mysql "$$MYSQL_DSN_VAL" up
migrate-up-to:
@command -v $(GOOSE) >/dev/null 2>&1 || { \
echo "goose not found. Install with: go install github.com/pressly/goose/v3/cmd/goose@latest"; \
exit 1; \
}
@MYSQL_DSN_VAL="$${MYSQL_DSN:-$$(grep -hE '^MYSQL_DSN=' .env .env.local 2>/dev/null | tail -n1 | cut -d= -f2-)}"; \
MYSQL_DSN_VAL="$${MYSQL_DSN_VAL#\"}"; MYSQL_DSN_VAL="$${MYSQL_DSN_VAL%\"}"; \
test -n "$$MYSQL_DSN_VAL" || { echo "MYSQL_DSN is required (set env or define in .env/.env.local)"; exit 1; }; \
test -n "$(v)" || { echo "usage: make migrate-up-to v=20260414"; exit 1; }; \
$(GOOSE) -dir $(GOOSE_DIR) mysql "$$MYSQL_DSN_VAL" up-to $(v)
migrate-apply:
@command -v $(GOOSE) >/dev/null 2>&1 || { \
echo "goose not found. Install with: go install github.com/pressly/goose/v3/cmd/goose@latest"; \
exit 1; \
}
@MYSQL_DSN_VAL="$${MYSQL_DSN:-$$(grep -hE '^MYSQL_DSN=' .env .env.local 2>/dev/null | tail -n1 | cut -d= -f2-)}"; \
MYSQL_DSN_VAL="$${MYSQL_DSN_VAL#\"}"; MYSQL_DSN_VAL="$${MYSQL_DSN_VAL%\"}"; \
test -n "$$MYSQL_DSN_VAL" || { echo "MYSQL_DSN is required (set env or define in .env/.env.local)"; exit 1; }; \
MODULES="$(modules)"; \
test -n "$$MODULES" || { \
echo "usage: make migrate-apply modules=opc,kassa"; \
echo "hint: use 'make migrate-reapply [from=YYYYMMDD]' for full down-to/up behavior"; \
exit 1; \
}; \
TMP_DIR="$$(mktemp -d)"; \
echo "Applying selected import apply migrations for modules: $$MODULES"; \
for m in $$(echo "$$MODULES" | tr ',' ' '); do \
FOUND="$$(ls -1 $(GOOSE_DIR)/*_$${m}_import_apply.sql 2>/dev/null || true)"; \
if [ -z "$$FOUND" ]; then \
echo "No apply migration found for module '$$m' in $(GOOSE_DIR)"; \
rm -rf "$$TMP_DIR"; \
exit 1; \
fi; \
for f in $$FOUND; do cp "$$f" "$$TMP_DIR"/; done; \
done; \
$(GOOSE) -dir "$$TMP_DIR" -no-versioning mysql "$$MYSQL_DSN_VAL" up; \
rm -rf "$$TMP_DIR"
migrate-reapply:
@command -v $(GOOSE) >/dev/null 2>&1 || { \
echo "goose not found. Install with: go install github.com/pressly/goose/v3/cmd/goose@latest"; \
exit 1; \
}
@MYSQL_DSN_VAL="$${MYSQL_DSN:-$$(grep -hE '^MYSQL_DSN=' .env .env.local 2>/dev/null | tail -n1 | cut -d= -f2-)}"; \
MYSQL_DSN_VAL="$${MYSQL_DSN_VAL#\"}"; MYSQL_DSN_VAL="$${MYSQL_DSN_VAL%\"}"; \
test -n "$$MYSQL_DSN_VAL" || { echo "MYSQL_DSN is required (set env or define in .env/.env.local)"; exit 1; }; \
FROM_VER="$(from)"; \
if [ -z "$$FROM_VER" ]; then FROM_VER="$(MIGRATE_APPLY_FROM)"; fi; \
echo "Re-applying migrations from version $$FROM_VER to latest"; \
$(GOOSE) -dir $(GOOSE_DIR) mysql "$$MYSQL_DSN_VAL" down-to "$$FROM_VER"; \
$(GOOSE) -dir $(GOOSE_DIR) mysql "$$MYSQL_DSN_VAL" up
hospital-apply: migrate-apply

435
README.md Normal file
View File

@@ -0,0 +1,435 @@
# Wucher Backend
Wucher is a Go backend built with GoFiber. The runtime uses MySQL for persistence and transient auth state, Amazon SQS for asynchronous email jobs, and Amazon SES v2 for email delivery.
## Prerequisites
- Go 1.25+
- MySQL
- LocalStack or AWS SQS/SES access for queue testing
## Project Structure
- `cmd/api/` API bootstrap
- `cmd/worker/` SQS worker bootstrap
- `internal/app/` application wiring
- `internal/resilience/` circuit breaker executor and dependency error classifiers
- `internal/service/` business logic
- `internal/transport/http/` GoFiber handlers and middleware
- `internal/repository/mysql/` MySQL repositories and transient stores
- `internal/repository/sqs/` SQS publisher/consumer adapter
- `internal/queue/` queue contracts, serializer, retry policy, and worker loop
## Runtime Architecture
- HTTP handlers stay thin and delegate to services.
- Auth and other business writes persist to MySQL first.
- API/service code never sends email synchronously.
- Email jobs are written to `email_outbox_messages` when outbox is enabled, or published directly to SQS when outbox is disabled.
- Worker publishes outbox rows to the SQS send queue, consumes SQS messages with long polling, and sends email through Amazon SES v2.
- SES bounce, complaint, and delivery tracking should use a separate flow:
- SES Configuration Set
- SNS topic subscription
- dedicated SQS event queue
- Circuit breakers are applied only at outbound dependency boundaries:
- Microsoft Entra token exchange
- SES delivery
- SQS publish/consume/ack calls
- Transient auth state previously kept in Redis is now stored in MySQL tables:
- `auth_runtime_tokens`
- `queue_idempotency_records`
`gorm` auto-migration creates these tables on startup.
## Getting Started
Install dependencies:
```bash
go mod tidy
```
Create local config from the example:
```bash
cp .env.example .env
```
Run the API:
```bash
go run ./cmd/api
```
Run the worker:
```bash
go run ./cmd/worker
```
Worker monitor endpoints:
```bash
curl http://127.0.0.1:9090/health
curl http://127.0.0.1:9090/metrics
curl http://127.0.0.1:9091/health
curl http://127.0.0.1:9091/metrics
```
- `/metrics` exposes Prometheus format for API and worker processes.
- `/health` remains JSON health/worker snapshot.
## Environment Variables
Example values are in `.env` for local development.
Important groups:
- Email delivery
- `EMAIL_PROVIDER`
- `EMAIL_MOCK_DIR`
- MySQL
- `MYSQL_DSN`
- `MYSQL_MAX_OPEN_CONNS`
- `MYSQL_MAX_IDLE_CONNS`
- `MYSQL_CONN_MAX_LIFETIME`
- `MYSQL_CONN_MAX_IDLE_TIME`
- Auth
- `AUTH_EMAIL_VERIFY_URL`
- `AUTH_PASSWORD_RESET_URL`
- `AUTH_SECURITY_PIN_RESET_URL`
- `AUTH_TOTP_CHALLENGE_TTL`
- `AUTH_SSO_STATE_TTL`
- `AUTH_JWT_ACCESS_SECRET`
- `AUTH_JWT_REFRESH_SECRET`
- `AUTH_WEBAUTHN_*`
- Queue / worker
- `QUEUE_MESSAGE_SCHEMA_VERSION`
- `QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK`
- `QUEUE_IDEMPOTENCY_KEY_PREFIX`
- `QUEUE_IDEMPOTENCY_TTL`
- `QUEUE_PROCESSING_TTL`
- `QUEUE_WORKER_CONCURRENCY`
- `QUEUE_WORKER_MAX_BATCH_SIZE`
- `QUEUE_WORKER_PROCESS_TIMEOUT`
- `QUEUE_WORKER_BACKOFF_MIN`
- `QUEUE_WORKER_BACKOFF_MAX`
- `QUEUE_WORKER_PERMANENT_FAILURE_DELAY`
- `QUEUE_OUTBOX_ENABLED`
- `QUEUE_OUTBOX_POLL_INTERVAL`
- `QUEUE_OUTBOX_BATCH_SIZE`
- `QUEUE_OUTBOX_DISPATCH_TIMEOUT`
- `QUEUE_OUTBOX_LOCK_TTL`
- `QUEUE_OUTBOX_MAX_ATTEMPTS`
- File queue / file worker
- `FILE_QUEUE_MESSAGE_SCHEMA_VERSION`
- `FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX`
- `FILE_QUEUE_IDEMPOTENCY_TTL`
- `FILE_QUEUE_PROCESSING_TTL`
- `FILE_QUEUE_WORKER_CONCURRENCY`
- `FILE_QUEUE_WORKER_MAX_BATCH_SIZE`
- `FILE_QUEUE_WORKER_PROCESS_TIMEOUT`
- `FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT`
- `FILE_QUEUE_WORKER_BACKOFF_MIN`
- `FILE_QUEUE_WORKER_BACKOFF_MAX`
- `FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY`
- `FILE_QUEUE_WORKER_MONITOR_ADDR`
- `FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD`
- `FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL`
- `FILE_QUEUE_OUTBOX_ENABLED`
- `FILE_QUEUE_OUTBOX_POLL_INTERVAL`
- `FILE_QUEUE_OUTBOX_BATCH_SIZE`
- `FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT`
- `FILE_QUEUE_OUTBOX_LOCK_TTL`
- `FILE_QUEUE_OUTBOX_MAX_ATTEMPTS`
- Circuit breakers
- `CB_DEFAULT_ENABLED`
- `CB_DEFAULT_MAX_REQUESTS`
- `CB_DEFAULT_INTERVAL`
- `CB_DEFAULT_BUCKET_PERIOD`
- `CB_DEFAULT_TIMEOUT`
- `CB_DEFAULT_MIN_REQUESTS`
- `CB_DEFAULT_FAILURE_RATIO`
- `CB_DEFAULT_CONSECUTIVE_FAILURES`
- `CB_MICROSOFT_SSO_ENABLED`
- `CB_SES_ENABLED`
- `CB_SQS_ENABLED`
- AWS / SES
- `AWS_REGION`
- `SES_REGION`
- `SES_FROM`
- `SES_CONFIGURATION_SET`
- `SES_SEND_TIMEOUT`
- `SES_ENDPOINT`
- `SES_ALLOW_INSECURE_ENDPOINT`
- AWS / SQS
- Email queue:
- `AWS_REGION`
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `SQS_ENDPOINT`
- `SQS_ALLOW_INSECURE_ENDPOINT`
- `SQS_QUEUE_URL`
- `SQS_QUEUE_TYPE`
- `SQS_MESSAGE_GROUP_ID`
- `SQS_MAX_MESSAGES`
- `SQS_WAIT_TIME_SECONDS`
- `SQS_VISIBILITY_TIMEOUT`
- File queue:
- `FILE_SQS_REGION`
- `FILE_SQS_ENDPOINT`
- `FILE_SQS_QUEUE_URL`
- `FILE_SQS_QUEUE_TYPE`
- `FILE_SQS_MESSAGE_GROUP_ID`
- `FILE_SQS_MAX_MESSAGES`
- `FILE_SQS_WAIT_TIME_SECONDS`
- `FILE_SQS_VISIBILITY_TIMEOUT`
- `FILE_SQS_ALLOW_INSECURE_ENDPOINT`
- File storage (S3)
- `FILE_STORAGE_PROVIDER`
- `FILE_S3_BUCKET`
- `FILE_S3_REGION`
- `FILE_S3_ENDPOINT`
- `FILE_S3_FORCE_PATH_STYLE`
- `FILE_S3_ALLOW_INSECURE_ENDPOINT`
- `FILE_S3_AUTO_CREATE_BUCKET`
- `FILE_OBJECT_KEY_PREFIX`
- `FILE_S3_PRESIGN_PUT_TTL`
- `FILE_S3_PRESIGN_GET_TTL`
- WOPI / Collabora
- `WOPI_TOKEN_SECRET`
- `WOPI_PROOF_SECRET`
- `WOPI_DISCOVERY_URL`
- `WOPI_PUBLIC_BASE_URL`
- `WOPI_EDIT_ACTION_URL`
- `WOPI_TOKEN_TTL`
- `WOPI_MAX_BODY_BYTES`
- `WOPI_REQUIRE_PROOF`
- Worker mode selector
- `WORKER_JOB=email`
- `WORKER_JOB=file_processing`
## WOPI / Collabora Hardening
- Keep WOPI endpoints behind the dedicated `/wopi` router and do not expose them through the session-authenticated app middleware chain.
- Use HTTPS end to end in production.
- Restrict network access to the Collabora host or private network allowlist.
- Set Collabora `aliasgroup`/host configuration so it can only call back to this backend.
- Keep `WOPI_TOKEN_SECRET` and `WOPI_PROOF_SECRET` in env or a secrets manager and rotate them periodically.
- Scope IAM to the minimum S3 bucket/prefix needed by file storage.
- Keep direct S3 URLs disabled for editor access; the backend should proxy file bytes.
- Ensure backend logs capture open/save/exit-save events for auditability.
- Verify `/hosting/discovery` reachability and S3 connectivity in health checks before enabling Collabora traffic.
Per-dependency overrides are supported with the same shape as the default block:
- `CB_MICROSOFT_SSO_*`
- `CB_SES_*`
- `CB_SQS_*`
Example: `CB_SES_TIMEOUT=45s` or `CB_SQS_CONSECUTIVE_FAILURES=3`.
## Circuit Breakers
- Breakers are scoped per outbound dependency, not per HTTP endpoint and not global middleware.
- MySQL repositories and internal business logic are intentionally not wrapped.
- Only dependency-health failures count toward opening the breaker.
- Microsoft SSO: network errors, timeouts, HTTP `429`, and `5xx`
- SES: network errors, throttling, and AWS server faults
- SQS: network errors, throttling, and AWS server faults
- Client/config/input errors are excluded so they do not open the breaker.
- State transitions are logged through the standard application logger.
## Queue Behavior
- Long polling is enabled through `SQS_WAIT_TIME_SECONDS`.
- Messages are deleted only after successful processing or explicit ack of duplicates.
- Transient failures use `ChangeMessageVisibility` with backoff.
- Permanent failures are left unacked so SQS redrive policy can move them to the DLQ.
- The default producer schema is `v2`; legacy payload fallback remains available for older messages.
- Configure DLQ on the queue itself in AWS/LocalStack. The application no longer publishes directly to a DLQ URL.
## Prometheus Metrics
Scrape endpoints:
- API: `http://<api-host>:8080/metrics`
- Email worker monitor: `http://<worker-host>:9090/metrics`
- File worker monitor: `http://<worker-host>:9091/metrics`
Example Prometheus scrape config:
```yaml
scrape_configs:
- job_name: "wucher-api"
static_configs:
- targets: ["127.0.0.1:8080"]
- job_name: "wucher-email-worker"
static_configs:
- targets: ["127.0.0.1:9090"]
- job_name: "wucher-file-worker"
static_configs:
- targets: ["127.0.0.1:9091"]
```
Metrics and labels:
- `app_aws_requests_total{component,service,operation,result}`
- `app_aws_request_duration_seconds{component,service,operation}`
- `app_aws_errors_total{component,service,operation,error_class}` where `error_class` is `4xx`, `5xx`, or `other`
- `app_aws_transferred_bytes_total{component,service,operation,direction}`
- `app_queue_messages_received_total{component,queue_name}`
- `app_queue_messages_deleted_total{component,queue_name}`
- `app_queue_visibility_changes_total{component,queue_name,result}`
- `app_queue_send_total{component,queue_name,result}`
- `app_queue_retries_total{component,queue_name,result}`
- `app_queue_message_bytes_total{component,queue_name,direction}`
- `app_queue_depth{component,queue_name}`
- `app_worker_jobs_processed_total{component,job_type,result}`
- `app_worker_job_duration_seconds{component,job_type}`
- `app_email_send_total{component,provider,result}`
- `app_email_send_duration_seconds{component,provider}`
- `app_file_upload_total{component,result}`
- `app_file_delete_total{component,result}`
- `app_file_head_total{component,result}`
- `app_file_presign_download_total{component,result}`
PromQL examples:
- Error rate per AWS operation:
```promql
sum by (component, service, operation) (rate(app_aws_requests_total{result="error"}[5m]))
/
clamp_min(sum by (component, service, operation) (rate(app_aws_requests_total[5m])), 1e-9)
```
- P95 latency per AWS operation:
```promql
histogram_quantile(
0.95,
sum by (le, component, service, operation) (
rate(app_aws_request_duration_seconds_bucket[5m])
)
)
```
- Queue depth:
```promql
max by (component, queue_name) (app_queue_depth)
```
- Worker success/failure count:
```promql
sum by (component, job_type, result) (increase(app_worker_jobs_processed_total[15m]))
```
- SES send failure rate:
```promql
sum by (component) (rate(app_email_send_total{provider="ses", result="error"}[5m]))
/
clamp_min(sum by (component) (rate(app_email_send_total{provider="ses"}[5m])), 1e-9)
```
- S3 4xx/5xx error rate:
```promql
sum by (operation, error_class) (rate(app_aws_errors_total{service="s3"}[5m]))
```
- S3 GetObject transferred bytes (estimated from presign download flow):
```promql
sum by (component) (rate(app_aws_transferred_bytes_total{service="s3",operation="getobject",direction="download"}[5m]))
```
- SQS message bytes in/out:
```promql
sum by (component, queue_name, direction) (rate(app_queue_message_bytes_total[5m]))
```
- SQS retry/requeue count:
```promql
sum by (component, queue_name, result) (increase(app_queue_retries_total[15m]))
```
## File Manager
- Queue separation:
- Email queue: `app-queue` (+ `app-queue-dlq`)
- File queue: `app-file-queue` (+ `app-file-queue-dlq`)
- `FILE_SQS_QUEUE_URL` must be different from `SQS_QUEUE_URL`.
- API startup validates queue separation and refuses startup on misconfiguration.
- Upload writes file metadata + file outbox durably; file queue publish is handled asynchronously by the file outbox dispatcher.
Local run checklist:
```bash
# 1) Start localstack (queue + bucket bootstrap script will run)
cd ../localstack && docker compose up -d
WORKER_JOB=file_processing go run ./cmd/worker
```
## SES Delivery Notes
- `EMAIL_PROVIDER=mock` is the recommended local/dev default when you do not have real AWS SES access.
- Mock delivery writes one JSON file per delivered email into `EMAIL_MOCK_DIR`.
- `SES_FROM` must be a verified identity or domain in Amazon SES.
- `SES_ENDPOINT` should be blank in production and only overridden for local/dev stacks such as LocalStack.
- Do not point SES events to the main send queue. Use `SES_CONFIGURATION_SET` with SNS -> separate SQS event queue.
- The codebase includes an SES event parser for SNS-wrapped notifications, but an event consumer is not started by default.
## Migration Notes
Detailed migration notes are in [docs/queue-sqs-migration.md](docs/queue-sqs-migration.md).
Data migration tracking wiki is in [docs/data-migration-wiki.md](docs/data-migration-wiki.md).
Main breaking changes:
- Redis runtime support has been removed from the codebase.
- Redis env vars are obsolete and should be removed from deployment config.
- SMTP runtime env/config has been removed in favor of SES.
- Worker now relies on MySQL for queue idempotency and auth/runtime state persistence.
- SQS must have a redrive policy and DLQ configured outside the application.
## CI / Build Selection
The pipeline always builds and pushes the `api` image on every push to `development` or `staging`.
To also build and push the worker or file-worker image, include the corresponding tag anywhere in the commit message:
| Tag | Image built | Docker target |
|---|---|---|
| `#PUSH-WORKER` | `mybitlab/wfm:<tag>-worker` | `worker` |
| `#PUSH-FILE-WORKER` | `mybitlab/wfm:<tag>-file-worker` | `file-worker` |
Both tags can appear in the same commit message to build all three in one pipeline run:
```
ci: deploy all services #PUSH-WORKER #PUSH-FILE-WORKER
```
Omitting a tag skips that image entirely — it will not be built or pushed.
## Testing
Run the full test suite:
```bash
go test ./...
```
Generate coverage:
```bash
go test ./... -coverprofile=coverage.out
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html
```

BIN
api Normal file

Binary file not shown.

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

310
config/local/config.json Normal file
View File

@@ -0,0 +1,310 @@
{
"env": "${APP_ENV}",
"http": {
"port": ${HTTP_PORT},
"bodyLimit": ${HTTP_BODY_LIMIT},
"timeouts": {
"read": "${HTTP_READ_TIMEOUT}",
"write": "${HTTP_WRITE_TIMEOUT}",
"idle": "${HTTP_IDLE_TIMEOUT}",
"request": "${HTTP_REQUEST_TIMEOUT}",
"requestOverrides": "${HTTP_REQUEST_TIMEOUT_OVERRIDES}",
"shutdown": "${HTTP_SHUTDOWN_TIMEOUT}"
},
"cors": {
"allowOrigins": "${HTTP_CORS_ALLOW_ORIGINS}",
"allowMethods": "${HTTP_CORS_ALLOW_METHODS}",
"allowHeaders": "${HTTP_CORS_ALLOW_HEADERS}",
"exposeHeaders": "${HTTP_CORS_EXPOSE_HEADERS}",
"allowCredentials": ${HTTP_CORS_ALLOW_CREDENTIALS},
"maxAge": ${HTTP_CORS_MAX_AGE}
},
"rateLimit": {
"max": ${HTTP_RATE_LIMIT_MAX},
"window": "${HTTP_RATE_LIMIT_WINDOW}",
"endpoints": "${HTTP_RATE_LIMIT_ENDPOINTS}"
},
"security": {
"hstsMaxAge": ${HTTP_SECURITY_HSTS_MAX_AGE}
}
},
"database": {
"mysql": {
"dsn": "${MYSQL_DSN}",
"maxOpenConns": ${MYSQL_MAX_OPEN_CONNS},
"maxIdleConns": ${MYSQL_MAX_IDLE_CONNS},
"connMaxLifetime": "${MYSQL_CONN_MAX_LIFETIME}",
"connMaxIdleTime": "${MYSQL_CONN_MAX_IDLE_TIME}"
}
},
"logging": {
"level": "${LOG_LEVEL}",
"format": "${LOG_FORMAT}"
},
"audit": {
"queueSize": ${AUDIT_LOG_QUEUE_SIZE},
"workers": ${AUDIT_LOG_WORKERS}
},
"email": {
"provider": "${EMAIL_PROVIDER}",
"mockDir": "${EMAIL_MOCK_DIR}"
},
"circuitBreaker": {
"default": {
"enabled": ${CB_DEFAULT_ENABLED},
"maxRequests": ${CB_DEFAULT_MAX_REQUESTS},
"interval": "${CB_DEFAULT_INTERVAL}",
"bucketPeriod": "${CB_DEFAULT_BUCKET_PERIOD}",
"timeout": "${CB_DEFAULT_TIMEOUT}",
"minRequests": ${CB_DEFAULT_MIN_REQUESTS},
"failureRatio": ${CB_DEFAULT_FAILURE_RATIO},
"consecutiveFailures": ${CB_DEFAULT_CONSECUTIVE_FAILURES}
},
"microsoftSSO": {
"enabled": ${CB_MICROSOFT_SSO_ENABLED},
"maxRequests": ${CB_MICROSOFT_SSO_MAX_REQUESTS},
"interval": "${CB_MICROSOFT_SSO_INTERVAL}",
"bucketPeriod": "${CB_MICROSOFT_SSO_BUCKET_PERIOD}",
"timeout": "${CB_MICROSOFT_SSO_TIMEOUT}",
"minRequests": ${CB_MICROSOFT_SSO_MIN_REQUESTS},
"failureRatio": ${CB_MICROSOFT_SSO_FAILURE_RATIO},
"consecutiveFailures": ${CB_MICROSOFT_SSO_CONSECUTIVE_FAILURES}
},
"ses": {
"enabled": ${CB_SES_ENABLED},
"maxRequests": ${CB_SES_MAX_REQUESTS},
"interval": "${CB_SES_INTERVAL}",
"bucketPeriod": "${CB_SES_BUCKET_PERIOD}",
"timeout": "${CB_SES_TIMEOUT}",
"minRequests": ${CB_SES_MIN_REQUESTS},
"failureRatio": ${CB_SES_FAILURE_RATIO},
"consecutiveFailures": ${CB_SES_CONSECUTIVE_FAILURES}
},
"sqs": {
"enabled": ${CB_SQS_ENABLED},
"maxRequests": ${CB_SQS_MAX_REQUESTS},
"interval": "${CB_SQS_INTERVAL}",
"bucketPeriod": "${CB_SQS_BUCKET_PERIOD}",
"timeout": "${CB_SQS_TIMEOUT}",
"minRequests": ${CB_SQS_MIN_REQUESTS},
"failureRatio": ${CB_SQS_FAILURE_RATIO},
"consecutiveFailures": ${CB_SQS_CONSECUTIVE_FAILURES}
}
},
"auth": {
"publicBaseURL": "${APP_PUBLIC_BASE_URL}",
"passwordPepper": "${AUTH_PASSWORD_PEPPER}",
"ipEncryptionKey": "${AUTH_IP_ENCRYPTION_KEY}",
"defaultRole": "${AUTH_DEFAULT_ROLE}",
"disableRegister": ${AUTH_DISABLE_REGISTER},
"hash": {
"time": ${AUTH_HASH_TIME},
"memoryKB": ${AUTH_HASH_MEMORY_KB},
"threads": ${AUTH_HASH_THREADS},
"keyLen": ${AUTH_HASH_KEY_LEN}
},
"email": {
"verifyTTL": "${AUTH_EMAIL_VERIFY_TTL}",
"verifyURL": "${AUTH_EMAIL_VERIFY_URL}",
"inviteTTL": "${AUTH_INVITE_TTL}"
},
"password": {
"setURL": "${AUTH_SET_PASSWORD_URL}",
"resetTTL": "${AUTH_PASSWORD_RESET_TTL}",
"resetURL": "${AUTH_PASSWORD_RESET_URL}",
"forgotEmailCooldown": "${AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN}",
"forgotMinDuration": "${AUTH_FORGOT_PASSWORD_MIN_DURATION}",
"forgotIPRateMax": ${AUTH_FORGOT_PASSWORD_IP_RATE_MAX},
"forgotIPRateWindow": "${AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW}",
"loginIPRateMax": ${AUTH_LOGIN_IP_RATE_MAX},
"loginIPRateWindow": "${AUTH_LOGIN_IP_RATE_WINDOW}"
},
"securityPin": {
"resetTTL": "${AUTH_SECURITY_PIN_RESET_TTL}",
"resetURL": "${AUTH_SECURITY_PIN_RESET_URL}",
"forgotEmailCooldown": "${AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN}",
"forgotMinDuration": "${AUTH_FORGOT_SECURITY_PIN_MIN_DURATION}",
"maxAttempts": ${AUTH_SECURITY_PIN_MAX_ATTEMPTS},
"lockDuration": "${AUTH_SECURITY_PIN_LOCK_DURATION}",
"actionTokenTTL": "${AUTH_SECURITY_PIN_ACTION_TOKEN_TTL}"
},
"jwt": {
"accessSecret": "${AUTH_JWT_ACCESS_SECRET}",
"refreshSecret": "${AUTH_JWT_REFRESH_SECRET}",
"accessTTL": "${AUTH_JWT_ACCESS_TTL}",
"refreshTTL": "${AUTH_JWT_REFRESH_TTL}",
"refreshTotpTTL": "${AUTH_JWT_REFRESH_TOTP_TTL}",
"cookie": {
"domain": "${AUTH_JWT_COOKIE_DOMAIN}",
"secure": ${AUTH_JWT_COOKIE_SECURE},
"sameSite": "${AUTH_JWT_COOKIE_SAMESITE}",
"accessName": "${AUTH_JWT_ACCESS_COOKIE}",
"refreshName": "${AUTH_JWT_REFRESH_COOKIE}"
}
},
"totp": {
"challengeTTL": "${AUTH_TOTP_CHALLENGE_TTL}"
},
"sso": {
"stateTTL": "${AUTH_SSO_STATE_TTL}",
"successRedirect": "${AUTH_SSO_SUCCESS_REDIRECT}"
},
"webauthn": {
"rpId": "${AUTH_WEBAUTHN_RP_ID}",
"rpDisplayName": "${AUTH_WEBAUTHN_RP_DISPLAY_NAME}",
"rpOrigins": "${AUTH_WEBAUTHN_RP_ORIGINS}",
"challengeTTL": "${AUTH_WEBAUTHN_CHALLENGE_TTL}"
}
},
"totp": {
"issuer": "${TOTP_ISSUER}",
"secretKey": "${TOTP_SECRET_KEY}"
},
"microsoftSSO": {
"tenantId": "${MS_ENTRA_TENANT_ID}",
"clientId": "${MS_ENTRA_CLIENT_ID}",
"clientSecret": "${MS_ENTRA_CLIENT_SECRET}",
"redirectUrl": "${MS_ENTRA_REDIRECT_URL}",
"authority": "${MS_ENTRA_AUTHORITY}",
"scopes": "${MS_ENTRA_SCOPES}"
},
"saml": {
"certFile": "${SAML_CERT_FILE}",
"keyFile": "${SAML_KEY_FILE}",
"metadataFile": "${SAML_METADATA_FILE}",
"rootUrl": "${SAML_ROOT_URL}",
"frontendUrl": "${FRONTEND_URL}",
"cookieDomain": "${COOKIE_DOMAIN}",
"cookieSecure": ${COOKIE_SECURE},
"sessionTTLHour": ${SESSION_TTL_HOUR}
},
"queue": {
"messageSchemaVersion": "${QUEUE_MESSAGE_SCHEMA_VERSION}",
"enableLegacyPayloadFallback": ${QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK},
"idempotencyKeyPrefix": "${QUEUE_IDEMPOTENCY_KEY_PREFIX}",
"idempotencyTTL": "${QUEUE_IDEMPOTENCY_TTL}",
"processingTTL": "${QUEUE_PROCESSING_TTL}",
"worker": {
"concurrency": ${QUEUE_WORKER_CONCURRENCY},
"maxBatchSize": ${QUEUE_WORKER_MAX_BATCH_SIZE},
"processTimeout": "${QUEUE_WORKER_PROCESS_TIMEOUT}",
"shutdownTimeout": "${QUEUE_WORKER_SHUTDOWN_TIMEOUT}",
"backoffMin": "${QUEUE_WORKER_BACKOFF_MIN}",
"backoffMax": "${QUEUE_WORKER_BACKOFF_MAX}",
"permanentFailureDelay": "${QUEUE_WORKER_PERMANENT_FAILURE_DELAY}",
"monitorAddr": "${QUEUE_WORKER_MONITOR_ADDR}",
"healthErrorThreshold": ${QUEUE_WORKER_HEALTH_ERROR_THRESHOLD},
"depthPollInterval": "${QUEUE_WORKER_DEPTH_POLL_INTERVAL}"
},
"outbox": {
"enabled": ${QUEUE_OUTBOX_ENABLED},
"pollInterval": "${QUEUE_OUTBOX_POLL_INTERVAL}",
"batchSize": ${QUEUE_OUTBOX_BATCH_SIZE},
"dispatchTimeout": "${QUEUE_OUTBOX_DISPATCH_TIMEOUT}",
"lockTTL": "${QUEUE_OUTBOX_LOCK_TTL}",
"maxAttempts": ${QUEUE_OUTBOX_MAX_ATTEMPTS}
}
},
"fileQueue": {
"messageSchemaVersion": "${FILE_QUEUE_MESSAGE_SCHEMA_VERSION}",
"idempotencyKeyPrefix": "${FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX}",
"idempotencyTTL": "${FILE_QUEUE_IDEMPOTENCY_TTL}",
"processingTTL": "${FILE_QUEUE_PROCESSING_TTL}",
"worker": {
"concurrency": ${FILE_QUEUE_WORKER_CONCURRENCY},
"maxBatchSize": ${FILE_QUEUE_WORKER_MAX_BATCH_SIZE},
"processTimeout": "${FILE_QUEUE_WORKER_PROCESS_TIMEOUT}",
"shutdownTimeout": "${FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT}",
"backoffMin": "${FILE_QUEUE_WORKER_BACKOFF_MIN}",
"backoffMax": "${FILE_QUEUE_WORKER_BACKOFF_MAX}",
"permanentFailureDelay": "${FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY}",
"monitorAddr": "${FILE_QUEUE_WORKER_MONITOR_ADDR}",
"healthErrorThreshold": ${FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD},
"depthPollInterval": "${FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL}"
},
"outbox": {
"enabled": ${FILE_QUEUE_OUTBOX_ENABLED},
"pollInterval": "${FILE_QUEUE_OUTBOX_POLL_INTERVAL}",
"batchSize": ${FILE_QUEUE_OUTBOX_BATCH_SIZE},
"dispatchTimeout": "${FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT}",
"lockTTL": "${FILE_QUEUE_OUTBOX_LOCK_TTL}",
"maxAttempts": ${FILE_QUEUE_OUTBOX_MAX_ATTEMPTS}
}
},
"aws": {
"region": "${AWS_REGION}",
"accessKeyId": "${AWS_ACCESS_KEY_ID}",
"secretAccessKey": "${AWS_SECRET_ACCESS_KEY}",
"sqs": {
"endpoint": "${SQS_ENDPOINT}",
"allowInsecureEndpoint": ${SQS_ALLOW_INSECURE_ENDPOINT},
"queueUrl": "${SQS_QUEUE_URL}",
"queueType": "${SQS_QUEUE_TYPE}",
"messageGroupId": "${SQS_MESSAGE_GROUP_ID}",
"maxMessages": ${SQS_MAX_MESSAGES},
"waitTimeSeconds": ${SQS_WAIT_TIME_SECONDS},
"visibilityTimeout": "${SQS_VISIBILITY_TIMEOUT}"
},
"fileSqs": {
"region": "${FILE_SQS_REGION}",
"endpoint": "${FILE_SQS_ENDPOINT}",
"allowInsecureEndpoint": ${FILE_SQS_ALLOW_INSECURE_ENDPOINT},
"queueUrl": "${FILE_SQS_QUEUE_URL}",
"queueType": "${FILE_SQS_QUEUE_TYPE}",
"messageGroupId": "${FILE_SQS_MESSAGE_GROUP_ID}",
"maxMessages": ${FILE_SQS_MAX_MESSAGES},
"waitTimeSeconds": ${FILE_SQS_WAIT_TIME_SECONDS},
"visibilityTimeout": "${FILE_SQS_VISIBILITY_TIMEOUT}"
},
"ses": {
"region": "${SES_REGION}",
"from": "${SES_FROM}",
"configurationSet": "${SES_CONFIGURATION_SET}",
"sendTimeout": "${SES_SEND_TIMEOUT}",
"endpoint": "${SES_ENDPOINT}",
"allowInsecureEndpoint": ${SES_ALLOW_INSECURE_ENDPOINT}
}
},
"file": {
"provider": "${FILE_STORAGE_PROVIDER}",
"s3": {
"bucket": "${FILE_S3_BUCKET}",
"region": "${FILE_S3_REGION}",
"endpoint": "${FILE_S3_ENDPOINT}",
"forcePathStyle": ${FILE_S3_FORCE_PATH_STYLE},
"allowInsecureEndpoint": ${FILE_S3_ALLOW_INSECURE_ENDPOINT},
"autoCreateBucket": ${FILE_S3_AUTO_CREATE_BUCKET},
"objectKeyPrefix": "${FILE_OBJECT_KEY_PREFIX}",
"presignPutTTL": "${FILE_S3_PRESIGN_PUT_TTL}",
"presignGetTTL": "${FILE_S3_PRESIGN_GET_TTL}"
},
"upload": {
"maxSize": ${FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES},
"allowedMimeTypes": "${FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES}",
"allowedExtensions": "${FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS}",
"allowEmptyFile": ${FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE},
"maxFilenameLength": ${FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH}
},
"cleanup": {
"enableS3Tagging": ${ENABLE_S3_TAGGING},
"enableCleanupJob": ${ENABLE_FILE_CLEANUP_JOB},
"tempFileTTLDays": ${TEMP_FILE_TTL_DAYS},
"orphanGracePeriodDays": ${ORPHAN_GRACE_PERIOD_DAYS},
"batchSize": ${FILE_CLEANUP_BATCH_SIZE},
"dryRun": ${FILE_CLEANUP_DRY_RUN}
}
},
"wopi": {
"tokenSecret": "${WOPI_TOKEN_SECRET}",
"proofSecret": "${WOPI_PROOF_SECRET}",
"discoveryUrl": "${WOPI_DISCOVERY_URL}",
"publicBaseUrl": "${WOPI_PUBLIC_BASE_URL}",
"editActionUrl": "${WOPI_EDIT_ACTION_URL}",
"tokenTTL": "${WOPI_TOKEN_TTL}",
"maxBodyBytes": ${WOPI_MAX_BODY_BYTES},
"requireProof": ${WOPI_REQUIRE_PROOF}
},
"gotenberg": {
"url": "${GOTENBERG_URL}"
}
}

307
config/prod/config.json Normal file
View File

@@ -0,0 +1,307 @@
{
"env": "${APP_ENV}",
"http": {
"port": ${HTTP_PORT},
"bodyLimit": ${HTTP_BODY_LIMIT},
"timeouts": {
"read": "${HTTP_READ_TIMEOUT}",
"write": "${HTTP_WRITE_TIMEOUT}",
"idle": "${HTTP_IDLE_TIMEOUT}",
"request": "${HTTP_REQUEST_TIMEOUT}",
"requestOverrides": "${HTTP_REQUEST_TIMEOUT_OVERRIDES}",
"shutdown": "${HTTP_SHUTDOWN_TIMEOUT}"
},
"cors": {
"allowOrigins": "${HTTP_CORS_ALLOW_ORIGINS}",
"allowMethods": "${HTTP_CORS_ALLOW_METHODS}",
"allowHeaders": "${HTTP_CORS_ALLOW_HEADERS}",
"exposeHeaders": "${HTTP_CORS_EXPOSE_HEADERS}",
"allowCredentials": ${HTTP_CORS_ALLOW_CREDENTIALS},
"maxAge": ${HTTP_CORS_MAX_AGE}
},
"rateLimit": {
"max": ${HTTP_RATE_LIMIT_MAX},
"window": "${HTTP_RATE_LIMIT_WINDOW}",
"endpoints": "${HTTP_RATE_LIMIT_ENDPOINTS}"
},
"security": {
"hstsMaxAge": ${HTTP_SECURITY_HSTS_MAX_AGE}
}
},
"database": {
"mysql": {
"dsn": "${MYSQL_DSN}",
"maxOpenConns": ${MYSQL_MAX_OPEN_CONNS},
"maxIdleConns": ${MYSQL_MAX_IDLE_CONNS},
"connMaxLifetime": "${MYSQL_CONN_MAX_LIFETIME}",
"connMaxIdleTime": "${MYSQL_CONN_MAX_IDLE_TIME}"
}
},
"logging": {
"level": "${LOG_LEVEL}",
"format": "${LOG_FORMAT}"
},
"audit": {
"queueSize": ${AUDIT_LOG_QUEUE_SIZE},
"workers": ${AUDIT_LOG_WORKERS}
},
"email": {
"provider": "${EMAIL_PROVIDER}",
"mockDir": "${EMAIL_MOCK_DIR}"
},
"circuitBreaker": {
"default": {
"enabled": ${CB_DEFAULT_ENABLED},
"maxRequests": ${CB_DEFAULT_MAX_REQUESTS},
"interval": "${CB_DEFAULT_INTERVAL}",
"bucketPeriod": "${CB_DEFAULT_BUCKET_PERIOD}",
"timeout": "${CB_DEFAULT_TIMEOUT}",
"minRequests": ${CB_DEFAULT_MIN_REQUESTS},
"failureRatio": ${CB_DEFAULT_FAILURE_RATIO},
"consecutiveFailures": ${CB_DEFAULT_CONSECUTIVE_FAILURES}
},
"microsoftSSO": {
"enabled": ${CB_MICROSOFT_SSO_ENABLED},
"maxRequests": ${CB_MICROSOFT_SSO_MAX_REQUESTS},
"interval": "${CB_MICROSOFT_SSO_INTERVAL}",
"bucketPeriod": "${CB_MICROSOFT_SSO_BUCKET_PERIOD}",
"timeout": "${CB_MICROSOFT_SSO_TIMEOUT}",
"minRequests": ${CB_MICROSOFT_SSO_MIN_REQUESTS},
"failureRatio": ${CB_MICROSOFT_SSO_FAILURE_RATIO},
"consecutiveFailures": ${CB_MICROSOFT_SSO_CONSECUTIVE_FAILURES}
},
"ses": {
"enabled": ${CB_SES_ENABLED},
"maxRequests": ${CB_SES_MAX_REQUESTS},
"interval": "${CB_SES_INTERVAL}",
"bucketPeriod": "${CB_SES_BUCKET_PERIOD}",
"timeout": "${CB_SES_TIMEOUT}",
"minRequests": ${CB_SES_MIN_REQUESTS},
"failureRatio": ${CB_SES_FAILURE_RATIO},
"consecutiveFailures": ${CB_SES_CONSECUTIVE_FAILURES}
},
"sqs": {
"enabled": ${CB_SQS_ENABLED},
"maxRequests": ${CB_SQS_MAX_REQUESTS},
"interval": "${CB_SQS_INTERVAL}",
"bucketPeriod": "${CB_SQS_BUCKET_PERIOD}",
"timeout": "${CB_SQS_TIMEOUT}",
"minRequests": ${CB_SQS_MIN_REQUESTS},
"failureRatio": ${CB_SQS_FAILURE_RATIO},
"consecutiveFailures": ${CB_SQS_CONSECUTIVE_FAILURES}
}
},
"auth": {
"publicBaseURL": "${APP_PUBLIC_BASE_URL}",
"passwordPepper": "${AUTH_PASSWORD_PEPPER}",
"ipEncryptionKey": "${AUTH_IP_ENCRYPTION_KEY}",
"defaultRole": "${AUTH_DEFAULT_ROLE}",
"disableRegister": ${AUTH_DISABLE_REGISTER},
"hash": {
"time": ${AUTH_HASH_TIME},
"memoryKB": ${AUTH_HASH_MEMORY_KB},
"threads": ${AUTH_HASH_THREADS},
"keyLen": ${AUTH_HASH_KEY_LEN}
},
"email": {
"verifyTTL": "${AUTH_EMAIL_VERIFY_TTL}",
"verifyURL": "${AUTH_EMAIL_VERIFY_URL}",
"inviteTTL": "${AUTH_INVITE_TTL}"
},
"password": {
"setURL": "${AUTH_SET_PASSWORD_URL}",
"resetTTL": "${AUTH_PASSWORD_RESET_TTL}",
"resetURL": "${AUTH_PASSWORD_RESET_URL}",
"forgotEmailCooldown": "${AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN}",
"forgotMinDuration": "${AUTH_FORGOT_PASSWORD_MIN_DURATION}",
"forgotIPRateMax": ${AUTH_FORGOT_PASSWORD_IP_RATE_MAX},
"forgotIPRateWindow": "${AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW}",
"loginIPRateMax": ${AUTH_LOGIN_IP_RATE_MAX},
"loginIPRateWindow": "${AUTH_LOGIN_IP_RATE_WINDOW}"
},
"securityPin": {
"resetTTL": "${AUTH_SECURITY_PIN_RESET_TTL}",
"resetURL": "${AUTH_SECURITY_PIN_RESET_URL}",
"forgotEmailCooldown": "${AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN}",
"forgotMinDuration": "${AUTH_FORGOT_SECURITY_PIN_MIN_DURATION}",
"maxAttempts": ${AUTH_SECURITY_PIN_MAX_ATTEMPTS},
"lockDuration": "${AUTH_SECURITY_PIN_LOCK_DURATION}",
"actionTokenTTL": "${AUTH_SECURITY_PIN_ACTION_TOKEN_TTL}"
},
"jwt": {
"accessSecret": "${AUTH_JWT_ACCESS_SECRET}",
"refreshSecret": "${AUTH_JWT_REFRESH_SECRET}",
"accessTTL": "${AUTH_JWT_ACCESS_TTL}",
"refreshTTL": "${AUTH_JWT_REFRESH_TTL}",
"refreshTotpTTL": "${AUTH_JWT_REFRESH_TOTP_TTL}",
"cookie": {
"domain": "${AUTH_JWT_COOKIE_DOMAIN}",
"secure": ${AUTH_JWT_COOKIE_SECURE},
"sameSite": "${AUTH_JWT_COOKIE_SAMESITE}",
"accessName": "${AUTH_JWT_ACCESS_COOKIE}",
"refreshName": "${AUTH_JWT_REFRESH_COOKIE}"
}
},
"totp": {
"challengeTTL": "${AUTH_TOTP_CHALLENGE_TTL}"
},
"sso": {
"stateTTL": "${AUTH_SSO_STATE_TTL}",
"successRedirect": "${AUTH_SSO_SUCCESS_REDIRECT}"
},
"webauthn": {
"rpId": "${AUTH_WEBAUTHN_RP_ID}",
"rpDisplayName": "${AUTH_WEBAUTHN_RP_DISPLAY_NAME}",
"rpOrigins": "${AUTH_WEBAUTHN_RP_ORIGINS}",
"challengeTTL": "${AUTH_WEBAUTHN_CHALLENGE_TTL}"
}
},
"totp": {
"issuer": "${TOTP_ISSUER}",
"secretKey": "${TOTP_SECRET_KEY}"
},
"microsoftSSO": {
"tenantId": "${MS_ENTRA_TENANT_ID}",
"clientId": "${MS_ENTRA_CLIENT_ID}",
"clientSecret": "${MS_ENTRA_CLIENT_SECRET}",
"redirectUrl": "${MS_ENTRA_REDIRECT_URL}",
"authority": "${MS_ENTRA_AUTHORITY}",
"scopes": "${MS_ENTRA_SCOPES}"
},
"saml": {
"certFile": "${SAML_CERT_FILE}",
"keyFile": "${SAML_KEY_FILE}",
"metadataFile": "${SAML_METADATA_FILE}",
"rootUrl": "${SAML_ROOT_URL}",
"frontendUrl": "${FRONTEND_URL}",
"cookieDomain": "${COOKIE_DOMAIN}",
"cookieSecure": ${COOKIE_SECURE},
"sessionTTLHour": ${SESSION_TTL_HOUR}
},
"queue": {
"messageSchemaVersion": "${QUEUE_MESSAGE_SCHEMA_VERSION}",
"enableLegacyPayloadFallback": ${QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK},
"idempotencyKeyPrefix": "${QUEUE_IDEMPOTENCY_KEY_PREFIX}",
"idempotencyTTL": "${QUEUE_IDEMPOTENCY_TTL}",
"processingTTL": "${QUEUE_PROCESSING_TTL}",
"worker": {
"concurrency": ${QUEUE_WORKER_CONCURRENCY},
"maxBatchSize": ${QUEUE_WORKER_MAX_BATCH_SIZE},
"processTimeout": "${QUEUE_WORKER_PROCESS_TIMEOUT}",
"shutdownTimeout": "${QUEUE_WORKER_SHUTDOWN_TIMEOUT}",
"backoffMin": "${QUEUE_WORKER_BACKOFF_MIN}",
"backoffMax": "${QUEUE_WORKER_BACKOFF_MAX}",
"permanentFailureDelay": "${QUEUE_WORKER_PERMANENT_FAILURE_DELAY}",
"monitorAddr": "${QUEUE_WORKER_MONITOR_ADDR}",
"healthErrorThreshold": ${QUEUE_WORKER_HEALTH_ERROR_THRESHOLD},
"depthPollInterval": "${QUEUE_WORKER_DEPTH_POLL_INTERVAL}"
},
"outbox": {
"enabled": ${QUEUE_OUTBOX_ENABLED},
"pollInterval": "${QUEUE_OUTBOX_POLL_INTERVAL}",
"batchSize": ${QUEUE_OUTBOX_BATCH_SIZE},
"dispatchTimeout": "${QUEUE_OUTBOX_DISPATCH_TIMEOUT}",
"lockTTL": "${QUEUE_OUTBOX_LOCK_TTL}",
"maxAttempts": ${QUEUE_OUTBOX_MAX_ATTEMPTS}
}
},
"fileQueue": {
"messageSchemaVersion": "${FILE_QUEUE_MESSAGE_SCHEMA_VERSION}",
"idempotencyKeyPrefix": "${FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX}",
"idempotencyTTL": "${FILE_QUEUE_IDEMPOTENCY_TTL}",
"processingTTL": "${FILE_QUEUE_PROCESSING_TTL}",
"worker": {
"concurrency": ${FILE_QUEUE_WORKER_CONCURRENCY},
"maxBatchSize": ${FILE_QUEUE_WORKER_MAX_BATCH_SIZE},
"processTimeout": "${FILE_QUEUE_WORKER_PROCESS_TIMEOUT}",
"shutdownTimeout": "${FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT}",
"backoffMin": "${FILE_QUEUE_WORKER_BACKOFF_MIN}",
"backoffMax": "${FILE_QUEUE_WORKER_BACKOFF_MAX}",
"permanentFailureDelay": "${FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY}",
"monitorAddr": "${FILE_QUEUE_WORKER_MONITOR_ADDR}",
"healthErrorThreshold": ${FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD},
"depthPollInterval": "${FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL}"
},
"outbox": {
"enabled": ${FILE_QUEUE_OUTBOX_ENABLED},
"pollInterval": "${FILE_QUEUE_OUTBOX_POLL_INTERVAL}",
"batchSize": ${FILE_QUEUE_OUTBOX_BATCH_SIZE},
"dispatchTimeout": "${FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT}",
"lockTTL": "${FILE_QUEUE_OUTBOX_LOCK_TTL}",
"maxAttempts": ${FILE_QUEUE_OUTBOX_MAX_ATTEMPTS}
}
},
"aws": {
"region": "${AWS_REGION}",
"accessKeyId": "${AWS_ACCESS_KEY_ID}",
"secretAccessKey": "${AWS_SECRET_ACCESS_KEY}",
"sqs": {
"endpoint": "${SQS_ENDPOINT}",
"allowInsecureEndpoint": ${SQS_ALLOW_INSECURE_ENDPOINT},
"queueUrl": "${SQS_QUEUE_URL}",
"queueType": "${SQS_QUEUE_TYPE}",
"messageGroupId": "${SQS_MESSAGE_GROUP_ID}",
"maxMessages": ${SQS_MAX_MESSAGES},
"waitTimeSeconds": ${SQS_WAIT_TIME_SECONDS},
"visibilityTimeout": "${SQS_VISIBILITY_TIMEOUT}"
},
"fileSqs": {
"region": "${FILE_SQS_REGION}",
"endpoint": "${FILE_SQS_ENDPOINT}",
"allowInsecureEndpoint": ${FILE_SQS_ALLOW_INSECURE_ENDPOINT},
"queueUrl": "${FILE_SQS_QUEUE_URL}",
"queueType": "${FILE_SQS_QUEUE_TYPE}",
"messageGroupId": "${FILE_SQS_MESSAGE_GROUP_ID}",
"maxMessages": ${FILE_SQS_MAX_MESSAGES},
"waitTimeSeconds": ${FILE_SQS_WAIT_TIME_SECONDS},
"visibilityTimeout": "${FILE_SQS_VISIBILITY_TIMEOUT}"
},
"ses": {
"region": "${SES_REGION}",
"from": "${SES_FROM}",
"configurationSet": "${SES_CONFIGURATION_SET}",
"sendTimeout": "${SES_SEND_TIMEOUT}",
"endpoint": "${SES_ENDPOINT}",
"allowInsecureEndpoint": ${SES_ALLOW_INSECURE_ENDPOINT}
}
},
"file": {
"provider": "${FILE_STORAGE_PROVIDER}",
"s3": {
"bucket": "${FILE_S3_BUCKET}",
"region": "${FILE_S3_REGION}",
"endpoint": "${FILE_S3_ENDPOINT}",
"forcePathStyle": ${FILE_S3_FORCE_PATH_STYLE},
"allowInsecureEndpoint": ${FILE_S3_ALLOW_INSECURE_ENDPOINT},
"autoCreateBucket": ${FILE_S3_AUTO_CREATE_BUCKET},
"objectKeyPrefix": "${FILE_OBJECT_KEY_PREFIX}",
"presignPutTTL": "${FILE_S3_PRESIGN_PUT_TTL}",
"presignGetTTL": "${FILE_S3_PRESIGN_GET_TTL}"
},
"upload": {
"maxSize": ${FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES},
"allowedMimeTypes": "${FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES}",
"allowedExtensions": "${FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS}",
"allowEmptyFile": ${FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE},
"maxFilenameLength": ${FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH}
},
"cleanup": {
"enableS3Tagging": ${ENABLE_S3_TAGGING},
"enableCleanupJob": ${ENABLE_FILE_CLEANUP_JOB},
"tempFileTTLDays": ${TEMP_FILE_TTL_DAYS},
"orphanGracePeriodDays": ${ORPHAN_GRACE_PERIOD_DAYS},
"batchSize": ${FILE_CLEANUP_BATCH_SIZE},
"dryRun": ${FILE_CLEANUP_DRY_RUN}
}
},
"wopi": {
"tokenSecret": "${WOPI_TOKEN_SECRET}",
"proofSecret": "${WOPI_PROOF_SECRET}",
"discoveryUrl": "${WOPI_DISCOVERY_URL}",
"publicBaseUrl": "${WOPI_PUBLIC_BASE_URL}",
"editActionUrl": "${WOPI_EDIT_ACTION_URL}",
"tokenTTL": "${WOPI_TOKEN_TTL}",
"maxBodyBytes": ${WOPI_MAX_BODY_BYTES},
"requireProof": ${WOPI_REQUIRE_PROOF}
}
}

307
config/staging/config.json Normal file
View File

@@ -0,0 +1,307 @@
{
"env": "${APP_ENV}",
"http": {
"port": ${HTTP_PORT},
"bodyLimit": ${HTTP_BODY_LIMIT},
"timeouts": {
"read": "${HTTP_READ_TIMEOUT}",
"write": "${HTTP_WRITE_TIMEOUT}",
"idle": "${HTTP_IDLE_TIMEOUT}",
"request": "${HTTP_REQUEST_TIMEOUT}",
"requestOverrides": "${HTTP_REQUEST_TIMEOUT_OVERRIDES}",
"shutdown": "${HTTP_SHUTDOWN_TIMEOUT}"
},
"cors": {
"allowOrigins": "${HTTP_CORS_ALLOW_ORIGINS}",
"allowMethods": "${HTTP_CORS_ALLOW_METHODS}",
"allowHeaders": "${HTTP_CORS_ALLOW_HEADERS}",
"exposeHeaders": "${HTTP_CORS_EXPOSE_HEADERS}",
"allowCredentials": ${HTTP_CORS_ALLOW_CREDENTIALS},
"maxAge": ${HTTP_CORS_MAX_AGE}
},
"rateLimit": {
"max": ${HTTP_RATE_LIMIT_MAX},
"window": "${HTTP_RATE_LIMIT_WINDOW}",
"endpoints": "${HTTP_RATE_LIMIT_ENDPOINTS}"
},
"security": {
"hstsMaxAge": ${HTTP_SECURITY_HSTS_MAX_AGE}
}
},
"database": {
"mysql": {
"dsn": "${MYSQL_DSN}",
"maxOpenConns": ${MYSQL_MAX_OPEN_CONNS},
"maxIdleConns": ${MYSQL_MAX_IDLE_CONNS},
"connMaxLifetime": "${MYSQL_CONN_MAX_LIFETIME}",
"connMaxIdleTime": "${MYSQL_CONN_MAX_IDLE_TIME}"
}
},
"logging": {
"level": "${LOG_LEVEL}",
"format": "${LOG_FORMAT}"
},
"audit": {
"queueSize": ${AUDIT_LOG_QUEUE_SIZE},
"workers": ${AUDIT_LOG_WORKERS}
},
"email": {
"provider": "${EMAIL_PROVIDER}",
"mockDir": "${EMAIL_MOCK_DIR}"
},
"circuitBreaker": {
"default": {
"enabled": ${CB_DEFAULT_ENABLED},
"maxRequests": ${CB_DEFAULT_MAX_REQUESTS},
"interval": "${CB_DEFAULT_INTERVAL}",
"bucketPeriod": "${CB_DEFAULT_BUCKET_PERIOD}",
"timeout": "${CB_DEFAULT_TIMEOUT}",
"minRequests": ${CB_DEFAULT_MIN_REQUESTS},
"failureRatio": ${CB_DEFAULT_FAILURE_RATIO},
"consecutiveFailures": ${CB_DEFAULT_CONSECUTIVE_FAILURES}
},
"microsoftSSO": {
"enabled": ${CB_MICROSOFT_SSO_ENABLED},
"maxRequests": ${CB_MICROSOFT_SSO_MAX_REQUESTS},
"interval": "${CB_MICROSOFT_SSO_INTERVAL}",
"bucketPeriod": "${CB_MICROSOFT_SSO_BUCKET_PERIOD}",
"timeout": "${CB_MICROSOFT_SSO_TIMEOUT}",
"minRequests": ${CB_MICROSOFT_SSO_MIN_REQUESTS},
"failureRatio": ${CB_MICROSOFT_SSO_FAILURE_RATIO},
"consecutiveFailures": ${CB_MICROSOFT_SSO_CONSECUTIVE_FAILURES}
},
"ses": {
"enabled": ${CB_SES_ENABLED},
"maxRequests": ${CB_SES_MAX_REQUESTS},
"interval": "${CB_SES_INTERVAL}",
"bucketPeriod": "${CB_SES_BUCKET_PERIOD}",
"timeout": "${CB_SES_TIMEOUT}",
"minRequests": ${CB_SES_MIN_REQUESTS},
"failureRatio": ${CB_SES_FAILURE_RATIO},
"consecutiveFailures": ${CB_SES_CONSECUTIVE_FAILURES}
},
"sqs": {
"enabled": ${CB_SQS_ENABLED},
"maxRequests": ${CB_SQS_MAX_REQUESTS},
"interval": "${CB_SQS_INTERVAL}",
"bucketPeriod": "${CB_SQS_BUCKET_PERIOD}",
"timeout": "${CB_SQS_TIMEOUT}",
"minRequests": ${CB_SQS_MIN_REQUESTS},
"failureRatio": ${CB_SQS_FAILURE_RATIO},
"consecutiveFailures": ${CB_SQS_CONSECUTIVE_FAILURES}
}
},
"auth": {
"publicBaseURL": "${APP_PUBLIC_BASE_URL}",
"passwordPepper": "${AUTH_PASSWORD_PEPPER}",
"ipEncryptionKey": "${AUTH_IP_ENCRYPTION_KEY}",
"defaultRole": "${AUTH_DEFAULT_ROLE}",
"disableRegister": ${AUTH_DISABLE_REGISTER},
"hash": {
"time": ${AUTH_HASH_TIME},
"memoryKB": ${AUTH_HASH_MEMORY_KB},
"threads": ${AUTH_HASH_THREADS},
"keyLen": ${AUTH_HASH_KEY_LEN}
},
"email": {
"verifyTTL": "${AUTH_EMAIL_VERIFY_TTL}",
"verifyURL": "${AUTH_EMAIL_VERIFY_URL}",
"inviteTTL": "${AUTH_INVITE_TTL}"
},
"password": {
"setURL": "${AUTH_SET_PASSWORD_URL}",
"resetTTL": "${AUTH_PASSWORD_RESET_TTL}",
"resetURL": "${AUTH_PASSWORD_RESET_URL}",
"forgotEmailCooldown": "${AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN}",
"forgotMinDuration": "${AUTH_FORGOT_PASSWORD_MIN_DURATION}",
"forgotIPRateMax": ${AUTH_FORGOT_PASSWORD_IP_RATE_MAX},
"forgotIPRateWindow": "${AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW}",
"loginIPRateMax": ${AUTH_LOGIN_IP_RATE_MAX},
"loginIPRateWindow": "${AUTH_LOGIN_IP_RATE_WINDOW}"
},
"securityPin": {
"resetTTL": "${AUTH_SECURITY_PIN_RESET_TTL}",
"resetURL": "${AUTH_SECURITY_PIN_RESET_URL}",
"forgotEmailCooldown": "${AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN}",
"forgotMinDuration": "${AUTH_FORGOT_SECURITY_PIN_MIN_DURATION}",
"maxAttempts": ${AUTH_SECURITY_PIN_MAX_ATTEMPTS},
"lockDuration": "${AUTH_SECURITY_PIN_LOCK_DURATION}",
"actionTokenTTL": "${AUTH_SECURITY_PIN_ACTION_TOKEN_TTL}"
},
"jwt": {
"accessSecret": "${AUTH_JWT_ACCESS_SECRET}",
"refreshSecret": "${AUTH_JWT_REFRESH_SECRET}",
"accessTTL": "${AUTH_JWT_ACCESS_TTL}",
"refreshTTL": "${AUTH_JWT_REFRESH_TTL}",
"refreshTotpTTL": "${AUTH_JWT_REFRESH_TOTP_TTL}",
"cookie": {
"domain": "${AUTH_JWT_COOKIE_DOMAIN}",
"secure": ${AUTH_JWT_COOKIE_SECURE},
"sameSite": "${AUTH_JWT_COOKIE_SAMESITE}",
"accessName": "${AUTH_JWT_ACCESS_COOKIE}",
"refreshName": "${AUTH_JWT_REFRESH_COOKIE}"
}
},
"totp": {
"challengeTTL": "${AUTH_TOTP_CHALLENGE_TTL}"
},
"sso": {
"stateTTL": "${AUTH_SSO_STATE_TTL}",
"successRedirect": "${AUTH_SSO_SUCCESS_REDIRECT}"
},
"webauthn": {
"rpId": "${AUTH_WEBAUTHN_RP_ID}",
"rpDisplayName": "${AUTH_WEBAUTHN_RP_DISPLAY_NAME}",
"rpOrigins": "${AUTH_WEBAUTHN_RP_ORIGINS}",
"challengeTTL": "${AUTH_WEBAUTHN_CHALLENGE_TTL}"
}
},
"totp": {
"issuer": "${TOTP_ISSUER}",
"secretKey": "${TOTP_SECRET_KEY}"
},
"microsoftSSO": {
"tenantId": "${MS_ENTRA_TENANT_ID}",
"clientId": "${MS_ENTRA_CLIENT_ID}",
"clientSecret": "${MS_ENTRA_CLIENT_SECRET}",
"redirectUrl": "${MS_ENTRA_REDIRECT_URL}",
"authority": "${MS_ENTRA_AUTHORITY}",
"scopes": "${MS_ENTRA_SCOPES}"
},
"saml": {
"certFile": "${SAML_CERT_FILE}",
"keyFile": "${SAML_KEY_FILE}",
"metadataFile": "${SAML_METADATA_FILE}",
"rootUrl": "${SAML_ROOT_URL}",
"frontendUrl": "${FRONTEND_URL}",
"cookieDomain": "${COOKIE_DOMAIN}",
"cookieSecure": ${COOKIE_SECURE},
"sessionTTLHour": ${SESSION_TTL_HOUR}
},
"queue": {
"messageSchemaVersion": "${QUEUE_MESSAGE_SCHEMA_VERSION}",
"enableLegacyPayloadFallback": ${QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK},
"idempotencyKeyPrefix": "${QUEUE_IDEMPOTENCY_KEY_PREFIX}",
"idempotencyTTL": "${QUEUE_IDEMPOTENCY_TTL}",
"processingTTL": "${QUEUE_PROCESSING_TTL}",
"worker": {
"concurrency": ${QUEUE_WORKER_CONCURRENCY},
"maxBatchSize": ${QUEUE_WORKER_MAX_BATCH_SIZE},
"processTimeout": "${QUEUE_WORKER_PROCESS_TIMEOUT}",
"shutdownTimeout": "${QUEUE_WORKER_SHUTDOWN_TIMEOUT}",
"backoffMin": "${QUEUE_WORKER_BACKOFF_MIN}",
"backoffMax": "${QUEUE_WORKER_BACKOFF_MAX}",
"permanentFailureDelay": "${QUEUE_WORKER_PERMANENT_FAILURE_DELAY}",
"monitorAddr": "${QUEUE_WORKER_MONITOR_ADDR}",
"healthErrorThreshold": ${QUEUE_WORKER_HEALTH_ERROR_THRESHOLD},
"depthPollInterval": "${QUEUE_WORKER_DEPTH_POLL_INTERVAL}"
},
"outbox": {
"enabled": ${QUEUE_OUTBOX_ENABLED},
"pollInterval": "${QUEUE_OUTBOX_POLL_INTERVAL}",
"batchSize": ${QUEUE_OUTBOX_BATCH_SIZE},
"dispatchTimeout": "${QUEUE_OUTBOX_DISPATCH_TIMEOUT}",
"lockTTL": "${QUEUE_OUTBOX_LOCK_TTL}",
"maxAttempts": ${QUEUE_OUTBOX_MAX_ATTEMPTS}
}
},
"fileQueue": {
"messageSchemaVersion": "${FILE_QUEUE_MESSAGE_SCHEMA_VERSION}",
"idempotencyKeyPrefix": "${FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX}",
"idempotencyTTL": "${FILE_QUEUE_IDEMPOTENCY_TTL}",
"processingTTL": "${FILE_QUEUE_PROCESSING_TTL}",
"worker": {
"concurrency": ${FILE_QUEUE_WORKER_CONCURRENCY},
"maxBatchSize": ${FILE_QUEUE_WORKER_MAX_BATCH_SIZE},
"processTimeout": "${FILE_QUEUE_WORKER_PROCESS_TIMEOUT}",
"shutdownTimeout": "${FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT}",
"backoffMin": "${FILE_QUEUE_WORKER_BACKOFF_MIN}",
"backoffMax": "${FILE_QUEUE_WORKER_BACKOFF_MAX}",
"permanentFailureDelay": "${FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY}",
"monitorAddr": "${FILE_QUEUE_WORKER_MONITOR_ADDR}",
"healthErrorThreshold": ${FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD},
"depthPollInterval": "${FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL}"
},
"outbox": {
"enabled": ${FILE_QUEUE_OUTBOX_ENABLED},
"pollInterval": "${FILE_QUEUE_OUTBOX_POLL_INTERVAL}",
"batchSize": ${FILE_QUEUE_OUTBOX_BATCH_SIZE},
"dispatchTimeout": "${FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT}",
"lockTTL": "${FILE_QUEUE_OUTBOX_LOCK_TTL}",
"maxAttempts": ${FILE_QUEUE_OUTBOX_MAX_ATTEMPTS}
}
},
"aws": {
"region": "${AWS_REGION}",
"accessKeyId": "${AWS_ACCESS_KEY_ID}",
"secretAccessKey": "${AWS_SECRET_ACCESS_KEY}",
"sqs": {
"endpoint": "${SQS_ENDPOINT}",
"allowInsecureEndpoint": ${SQS_ALLOW_INSECURE_ENDPOINT},
"queueUrl": "${SQS_QUEUE_URL}",
"queueType": "${SQS_QUEUE_TYPE}",
"messageGroupId": "${SQS_MESSAGE_GROUP_ID}",
"maxMessages": ${SQS_MAX_MESSAGES},
"waitTimeSeconds": ${SQS_WAIT_TIME_SECONDS},
"visibilityTimeout": "${SQS_VISIBILITY_TIMEOUT}"
},
"fileSqs": {
"region": "${FILE_SQS_REGION}",
"endpoint": "${FILE_SQS_ENDPOINT}",
"allowInsecureEndpoint": ${FILE_SQS_ALLOW_INSECURE_ENDPOINT},
"queueUrl": "${FILE_SQS_QUEUE_URL}",
"queueType": "${FILE_SQS_QUEUE_TYPE}",
"messageGroupId": "${FILE_SQS_MESSAGE_GROUP_ID}",
"maxMessages": ${FILE_SQS_MAX_MESSAGES},
"waitTimeSeconds": ${FILE_SQS_WAIT_TIME_SECONDS},
"visibilityTimeout": "${FILE_SQS_VISIBILITY_TIMEOUT}"
},
"ses": {
"region": "${SES_REGION}",
"from": "${SES_FROM}",
"configurationSet": "${SES_CONFIGURATION_SET}",
"sendTimeout": "${SES_SEND_TIMEOUT}",
"endpoint": "${SES_ENDPOINT}",
"allowInsecureEndpoint": ${SES_ALLOW_INSECURE_ENDPOINT}
}
},
"file": {
"provider": "${FILE_STORAGE_PROVIDER}",
"s3": {
"bucket": "${FILE_S3_BUCKET}",
"region": "${FILE_S3_REGION}",
"endpoint": "${FILE_S3_ENDPOINT}",
"forcePathStyle": ${FILE_S3_FORCE_PATH_STYLE},
"allowInsecureEndpoint": ${FILE_S3_ALLOW_INSECURE_ENDPOINT},
"autoCreateBucket": ${FILE_S3_AUTO_CREATE_BUCKET},
"objectKeyPrefix": "${FILE_OBJECT_KEY_PREFIX}",
"presignPutTTL": "${FILE_S3_PRESIGN_PUT_TTL}",
"presignGetTTL": "${FILE_S3_PRESIGN_GET_TTL}"
},
"upload": {
"maxSize": ${FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES},
"allowedMimeTypes": "${FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES}",
"allowedExtensions": "${FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS}",
"allowEmptyFile": ${FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE},
"maxFilenameLength": ${FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH}
},
"cleanup": {
"enableS3Tagging": ${ENABLE_S3_TAGGING},
"enableCleanupJob": ${ENABLE_FILE_CLEANUP_JOB},
"tempFileTTLDays": ${TEMP_FILE_TTL_DAYS},
"orphanGracePeriodDays": ${ORPHAN_GRACE_PERIOD_DAYS},
"batchSize": ${FILE_CLEANUP_BATCH_SIZE},
"dryRun": ${FILE_CLEANUP_DRY_RUN}
}
},
"wopi": {
"tokenSecret": "${WOPI_TOKEN_SECRET}",
"proofSecret": "${WOPI_PROOF_SECRET}",
"discoveryUrl": "${WOPI_DISCOVERY_URL}",
"publicBaseUrl": "${WOPI_PUBLIC_BASE_URL}",
"editActionUrl": "${WOPI_EDIT_ACTION_URL}",
"tokenTTL": "${WOPI_TOKEN_TTL}",
"maxBodyBytes": ${WOPI_MAX_BODY_BYTES},
"requireProof": ${WOPI_REQUIRE_PROOF}
}
}

2
configs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
saml.key
metadata.xml

19
configs/saml.crt Normal file
View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUIC8zX/OqMbovxzuzGOHr7UJVucQwDQYJKoZIhvcNAQEL
BQAwIjEgMB4GA1UEAwwXYXBpLXdmbS1kZXYubXliaXQuY28uaWQwHhcNMjYwNTI1
MDMxMjE5WhcNMzYwNTIyMDMxMjE5WjAiMSAwHgYDVQQDDBdhcGktd2ZtLWRldi5t
eWJpdC5jby5pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMX3z2aC
OO12ZwuNxxLf35m7rzIGB6J5O8EIgqihmg4ciy3BIkiDZAazAXaXR6ZDzLp63XMT
9a6zEA3+U+LIovxcpgK2znrfxxFI0QehpTxzQj+GIkClyy5srewYHIWrQu+ShaXn
btsjVwjELAGLzD8fLlIbmvrqr5zTCzq9Ep6ZZWCA8V8HAEXlR8lmC7vcu9bCCt5J
7zTE5JODnxTFawJpcbpBgudL/0x44ed3aglLQsfdk7mB4FiUOXup8aOefZHRzayl
n8jNSGH98kzw21vYV8bJUuwp9wNDqVNpM7LKIkdC4m+VgoTP/CM+GfRAnfpLhoP0
oaxV6wUqmlxceQMCAwEAAaNTMFEwHQYDVR0OBBYEFHt5Aeik4E5BC6j0wc2IcfAa
FlSpMB8GA1UdIwQYMBaAFHt5Aeik4E5BC6j0wc2IcfAaFlSpMA8GA1UdEwEB/wQF
MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHbt3T5iti9uguOqekecWCzti3bE41W/
pfgT7Ub2XXU3m217tfl4EDEHf/FagNn3KtBufKsoV/57eFWb9wvCE4KAltUi0Qzn
xQtCU/80TgNErnPhPR72LB4HxWFOA+k7WobY0+PYiedRJSf8vsrho/UgkvePPtp4
3jfmN0By9/d+KqHNilPDx1AbJEboQckp/xIEfgAoCfsXSNYeWUuQlKu9eqXaz8Om
lkUVcHfR5GBxSzqMvbbMe2EYNpB2Ap+ih/KzoBLEmbkUSeaSnUbhYYsMIHFl3usp
7Z3sEBWCfeYZeLlvsNNMse7zkBB+TyOtPDlcPXhZAY9225S2q/sd/aY=
-----END CERTIFICATE-----

5165
coverage.html Normal file

File diff suppressed because it is too large Load Diff

114
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,114 @@
services:
api:
image: mybitlab/wfm:be-dev
env_file:
- .env
environment:
HTTP_PORT: "8080"
APP_ENV: development
CONFIG_BASE_PATH: /opt/flight-manager/development
volumes:
- /opt/flight-manager/development/config.generated.json:/opt/flight-manager/development/config.generated.json:ro
- ./configs:/app/configs:ro
ports:
- 8086:8080
restart: unless-stopped
networks:
- wucher
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:8080/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
worker:
image: mybitlab/wfm:be-dev-worker
env_file:
- .env
environment:
QUEUE_WORKER_MONITOR_ADDR: 0.0.0.0:9090
WORKER_JOB: email
APP_ENV: development
CONFIG_BASE_PATH: /opt/flight-manager/development
volumes:
- /opt/flight-manager/development/config.generated.json:/opt/flight-manager/development/config.generated.json:ro
expose:
- 9090
# ports:
# - 9092:9090
restart: unless-stopped
networks:
- wucher
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:9090/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
file-worker:
image: mybitlab/wfm:be-dev-file-worker
env_file:
- .env
environment:
QUEUE_WORKER_MONITOR_ADDR: 0.0.0.0:9091
WORKER_JOB: file_processing
APP_ENV: development
CONFIG_BASE_PATH: /opt/flight-manager/development
volumes:
- /opt/flight-manager/development/config.generated.json:/opt/flight-manager/development/config.generated.json:ro
expose:
- 9091
# ports:
# - 9093:9091
restart: unless-stopped
networks:
- wucher
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:9091/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
networks:
wucher:
name: wucher
driver: bridge
db_net:
external: true

110
docker-compose.staging.yml Normal file
View File

@@ -0,0 +1,110 @@
services:
api:
image: mybitlab/wfm:be-staging
env_file:
- .env
environment:
HTTP_PORT: "8080"
APP_ENV: staging
CONFIG_BASE_PATH: /opt/flight-manager/staging
volumes:
- /opt/flight-manager/staging/config.generated.json:/opt/flight-manager/staging/config.generated.json:ro
- ./configs:/app/configs:ro
ports:
- 8087:8080
restart: unless-stopped
networks:
- fm-staging
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:8080/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
worker:
image: mybitlab/wfm:be-worker-staging
env_file:
- .env
environment:
QUEUE_WORKER_MONITOR_ADDR: 0.0.0.0:9090
WORKER_JOB: email
APP_ENV: staging
CONFIG_BASE_PATH: /opt/flight-manager/staging
volumes:
- /opt/flight-manager/staging/config.generated.json:/opt/flight-manager/staging/config.generated.json:ro
expose:
- 9090
restart: unless-stopped
networks:
- fm-staging
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:9090/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
file-worker:
image: mybitlab/wfm:be-staging-file-worker
env_file:
- .env
environment:
QUEUE_WORKER_MONITOR_ADDR: 0.0.0.0:9091
WORKER_JOB: file_processing
APP_ENV: staging
CONFIG_BASE_PATH: /opt/flight-manager/staging
volumes:
- /opt/flight-manager/staging/config.generated.json:/opt/flight-manager/staging/config.generated.json:ro
expose:
- 9091
restart: unless-stopped
networks:
- fm-staging
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:9091/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
networks:
fm-staging:
external: true
db_net:
external: true

110
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,110 @@
services:
api:
# Reuses the staging image — test differs only by its .env + DB (managed at deploy)
image: mybitlab/wfm:be-staging
env_file:
- .env
environment:
HTTP_PORT: "8080"
APP_ENV: test
CONFIG_BASE_PATH: /opt/flight-manager/test
volumes:
- /opt/flight-manager/test/config.generated.json:/opt/flight-manager/test/config.generated.json:ro
- ./configs:/app/configs:ro
ports:
- 8092:8080
restart: unless-stopped
networks:
- fm-test
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:8080/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
worker:
image: mybitlab/wfm:be-worker-staging
env_file:
- .env
environment:
QUEUE_WORKER_MONITOR_ADDR: 0.0.0.0:9090
WORKER_JOB: email
APP_ENV: test
CONFIG_BASE_PATH: /opt/flight-manager/test
volumes:
- /opt/flight-manager/test/config.generated.json:/opt/flight-manager/test/config.generated.json:ro
expose:
- 9090
restart: unless-stopped
networks:
- fm-test
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:9090/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
file-worker:
image: mybitlab/wfm:be-staging-file-worker
env_file:
- .env
environment:
QUEUE_WORKER_MONITOR_ADDR: 0.0.0.0:9091
WORKER_JOB: file_processing
APP_ENV: test
CONFIG_BASE_PATH: /opt/flight-manager/test
volumes:
- /opt/flight-manager/test/config.generated.json:/opt/flight-manager/test/config.generated.json:ro
expose:
- 9091
restart: unless-stopped
networks:
- fm-test
- db_net
extra_hosts:
- host.docker.internal:host-gateway
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- /app/healthcheck
- http://127.0.0.1:9091/health
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
labels:
- "logging=enabled"
networks:
fm-test:
external: true
db_net:
external: true

86
docker-compose.yml Normal file
View File

@@ -0,0 +1,86 @@
# This stack runs API, email worker, and file worker containers.
# Ensure envs in .env point to endpoints reachable from inside containers.
# In particular, MYSQL_DSN/SQS/SES values using 127.0.0.1 will not work unless they are
# changed to a host or service name that containers can reach.
services:
api:
build:
context: .
dockerfile: Dockerfile
target: api
image: localhost/wucher-api:dev
env_file:
- .env
environment:
HTTP_PORT: "8080"
ports:
- "8080:8080"
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "/app/healthcheck", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
worker:
build:
context: .
dockerfile: Dockerfile
target: worker
image: localhost/wucher-worker:dev
env_file:
- .env
environment:
# Bind to all interfaces so the monitor endpoint can be reached from the host.
QUEUE_WORKER_MONITOR_ADDR: "0.0.0.0:9090"
WORKER_JOB: "email"
ports:
- "9090:9090"
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "/app/healthcheck", "http://127.0.0.1:9090/health"]
interval: 30s
timeout: 3s
start_period: 20s
retries: 3
gotenberg:
image: gotenberg/gotenberg:8
ports:
- "3000:3000"
restart: unless-stopped
file-worker:
build:
context: .
dockerfile: Dockerfile
target: file-worker
image: localhost/wucher-file-worker:dev
env_file:
- .env
environment:
# Bind to all interfaces so the monitor endpoint can be reached from the host.
QUEUE_WORKER_MONITOR_ADDR: "0.0.0.0:9091"
WORKER_JOB: "file_processing"
ports:
- "9091:9091"
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "/app/healthcheck", "http://127.0.0.1:9091/health"]
interval: 30s
timeout: 3s
start_period: 20s
retries: 3

423
docs/API_GUIDE.md Normal file
View File

@@ -0,0 +1,423 @@
# API Guide STEP 1 & STEP 2
Dokumentasi ringkas untuk endpoint yang paling sering dipakai di backend ini.
## Helicopter APIs
### 1. Create helicopter
- `POST /api/v1/helicopters/create`
- Fungsi: membuat data helicopter baru.
- Cara pakai: kirim payload create helicopter sesuai schema di Swagger.
### 2. Update helicopter
- `PATCH /api/v1/helicopters/update/{id}`
- Fungsi: update sebagian field helicopter berdasarkan `id`.
- Cara pakai: kirim field yang ingin diubah saja.
### 3. Delete helicopter
- `DELETE /api/v1/helicopters/delete/{id}`
- Fungsi: soft delete helicopter.
- Cara pakai: kirim `id` helicopter di path.
### 4. Generate next report number
- `POST /api/v1/helicopters/{id}/reports/next-number`
- Fungsi: generate nomor report berikutnya dengan format `<identifier>-NNNN`.
- Cara pakai: kirim `id` helicopter di path.
### 5. Get helicopter by ID
- `GET /api/v1/helicopters/get/{id}`
- Fungsi: ambil detail helicopter berdasarkan `id`.
- Cara pakai: kirim `id` helicopter di path.
### 6. Get all helicopters
- `GET /api/v1/helicopters/get-all`
- Fungsi: list helicopter dengan filter, sort, dan pagination.
- Cara pakai:
- `search` atau `filter[search]`
- `sort`
- `page[number]`, `page[size]`
- `page`, `size`, `limit`
### 7. Get all helicopters (datatable)
- `GET /api/v1/helicopters/get-all/dt`
- Fungsi: list helicopter untuk datatable.
- Cara pakai: gunakan parameter pagination datatable yang didukung backend.
## Bases APIs
### 1. Create base
- `POST /api/v1/bases/create`
- Fungsi: membuat base baru.
- Cara pakai: kirim payload base create sesuai Swagger.
### 2. Update base
- `PATCH /api/v1/bases/update/{uuid}`
- Fungsi: update sebagian field base.
- Cara pakai: kirim `uuid` base di path, lalu field yang ingin diubah.
### 3. Delete base
- `DELETE /api/v1/bases/delete/{uuid}`
- Fungsi: soft delete base.
- Cara pakai: kirim `uuid` base di path.
### 4. Get base by ID
- `GET /api/v1/bases/get/{uuid}`
- Fungsi: ambil detail base.
- Cara pakai: kirim `uuid` base di path.
### 5. Get all bases
- `GET /api/v1/bases/get-all`
- Fungsi: list base dengan filter, sort, dan pagination.
- Cara pakai:
- `search` atau `filter[search]`
- `sort`
- `page[number]`, `page[size]`
- `page`, `size`, `limit`
### 6. Get all bases (datatable)
- `GET /api/v1/bases/get-all/dt`
- Fungsi: list base untuk datatable.
- Cara pakai: gunakan parameter pagination datatable yang didukung backend.
## Contact APIs
### 1. Create contact
- `POST /api/v1/contacts/create`
- Fungsi: membuat user contact baru beserta profile role-specific.
- Cara pakai:
- `data.type` harus `contact_create`
- `role_ids` menentukan role
- setiap role yang dipilih harus punya object profile yang sesuai di `attributes`
### 2. Update contact
- `PATCH /api/v1/contacts/{user_id}`
- Fungsi: update contact.
- Cara pakai:
- `data.type` harus `contact_update`
- bisa update `role_id` dan `role_ids`
- `role_ids: []` akan menghapus semua role
- hanya satu profile role-specific boleh ada di `attributes`
### 3. Get contact
- `GET /api/v1/contacts/{user_id}`
- Fungsi: ambil detail contact.
- Cara pakai: kirim `user_id` di path.
### 4. Get all contacts
- `GET /api/v1/contacts/get-all`
- Fungsi: list contact dengan filter role, pilot category, sort, dan pagination.
- Cara pakai:
- filter role / pilot category sesuai kebutuhan frontend
- sort field seperti `name`, `email`, `is_active`, `created_at`, `updated_at`, dan lainnya
### 5. Get all contacts (datatable)
- `GET /api/v1/contacts/get-all/dt`
- Fungsi: list contact untuk datatable.
- Cara pakai: gunakan parameter pagination datatable yang didukung backend.
### 6. Delete contact
- `DELETE /api/v1/contacts/{user_id}`
- Fungsi: soft delete contact.
- Cara pakai: kirim `user_id` di path.
### 7. Update contact active status
- `PATCH /api/v1/contacts/{user_id}/status`
- Fungsi: update status aktif contact.
- Cara pakai: kirim `user_id` di path dan payload status.
### 8. Update contact role
- `PATCH /api/v1/contacts/{user_id}/role`
- Fungsi: update role contact.
- Cara pakai: kirim `user_id` di path dan payload role baru.
### 9. Update pilot category
- `PATCH /api/v1/contacts/{user_id}/pilot-category`
- Fungsi: update pilot category.
- Cara pakai: kirim `user_id` di path dan payload kategori.
### 10. Update contact license fields
- `PATCH /api/v1/contacts/{user_id}/license`
- Fungsi: update data lisensi contact.
- Cara pakai: kirim `user_id` di path dan payload field lisensi.
### 11. Update chief flags
- `PATCH /api/v1/contacts/{user_id}/chief-flags`
- Fungsi: update flag chief.
- Cara pakai: kirim `user_id` di path dan payload flag yang ingin diubah.
### 12. Update responsible complaints flag
- `PATCH /api/v1/contacts/{user_id}/responsible-complaints`
- Fungsi: update flag responsible complaints.
- Cara pakai: kirim `user_id` di path dan payload flag yang ingin diubah.
### 13. Bulk update contacts status
- `POST /api/v1/contacts/bulk-update`
- Fungsi: update status banyak contact sekaligus.
- Cara pakai: kirim daftar contact yang ingin diupdate.
## Flights API
### Get all flights
- `GET /api/v1/flights/get-all`
- Fungsi: list flight dengan pagination, filter, sort, dan step workflow.
- Cara pakai:
- `search` atau `filter[search]`
- `date` atau `filter[date]` untuk filter tanggal flight
- `sort`
- `page[number]`, `page[size]`
- `page`, `size`, `limit`
### Contoh response
```json
{
"data": [
{
"type": "flight",
"id": "019e8afa-e602-716c-8d2a-b6b11a89caf0",
"attributes": {
"status": "",
"mission_code": "M-060326-001",
"date": "2026-06-03",
"total_draft": 2,
"takeover": {
"id": "019e8afa-e5fe-7de0-ac5d-fb5ab95896b0",
"daily_inspection": {
"name": "test test",
"short_name": "",
"date": "05.06.2026",
"utc_time": "05:03"
}
},
"steps": [
{
"phase": "takeover",
"position": "takeover",
"step": 1,
"complete": true,
"id": "019e8afa-e5fe-7de0-ac5d-fb5ab95896b0",
"status": "completed",
"completed_at": "2026-06-03T00:55:59Z",
"completed_by": "test test"
},
{
"phase": "mission",
"step": 2,
"complete": true,
"status": "assigned",
"mission_type": [
{
"type": "CAT",
"draft": 2,
"completed": 0
}
],
"completed_at": "",
"completed_by": ""
}
],
"draft": [
{
"type": "CAT",
"items": [
{
"id": "019e9640-f7af-7940-80e8-1e9a83013afb",
"type": "CAT",
"status": "draft",
"flight_data_id": "019e9640-f7b0-7787-b43c-0b395ff2792a"
},
{
"id": "019e9640-f8cf-72b1-84dc-b50c1a3c2cc6",
"type": "CAT",
"status": "draft",
"flight_data_id": "019e9640-f8d0-7ea2-b250-a7e31bfba95f"
}
]
}
],
"created_at": "2026-06-03T00:55:59Z",
"created_by": "test test",
"updated_at": "2026-06-03T00:55:59Z",
"updated_by": "test test"
}
}
],
"meta": {
"page_number": 1,
"page_size": 20,
"total": 3
}
}
```
## Takeover APIs
### 1. Create takeover
- `POST /api/v1/takeovers/create`
- Fungsi: membuat takeover secara atomik.
- Cara pakai:
- `base_id`, `duty_date`, `helicopter_id`
- `inspection`
- `roster_detail`
- `notes`
- Catatan:
- `shift_date` dan `shift_time` sudah tidak dipakai lagi di takeover request.
- shift diambil dari `base`.
### 2. Update takeover
- `PATCH /api/v1/takeovers/update/{id}`
- Fungsi: replace takeover berdasarkan flight UUID.
- Cara pakai:
- kirim `id` takeover / flight di path
- body mengikuti format takeover update yang sama dengan create
### 3. Get takeover by ID
- `GET /api/v1/takeovers/get/{id}`
- Fungsi: ambil takeover detail.
- Cara pakai: kirim `id` di path.
### 4. Get all takeovers
- `GET /api/v1/takeovers/get-all`
- Fungsi: list takeover composite yang dibangun dari flight, reserve AC, duty roster, dan inspection.
- Cara pakai: tidak ada parameter wajib.
### 5. Delete takeover
- `DELETE /api/v1/takeovers/delete/{id}`
- Fungsi: delete takeover berdasarkan flight UUID.
- Cara pakai: kirim `id` di path.
### Payload create/update takeover
```json
{
"data": {
"attributes": {
"base_id": "string",
"duty_date": "string",
"helicopter_id": "string",
"inspection": {
"before": {
"file_checklist": [
{
"helicopter_file_id": "019d7000-aaaa-7bbb-8ccc-111111111111",
"is_done": true
}
],
"fuel_added_amount": 0,
"fuel_amount": 0,
"fuel_unit": "string",
"hydraulic_lh_checked": true,
"hydraulic_rh_checked": true,
"note": "string",
"oil_engine_nr1_checked": true,
"oil_engine_nr2_checked": true,
"oil_transmission_igb_checked": true,
"oil_transmission_mgb_checked": true,
"oil_transmission_tgb_checked": true
},
"inspection_date": "2026-04-15",
"prepare": {
"file_checklist": [
{
"helicopter_file_id": "019d7000-aaaa-7bbb-8ccc-111111111111",
"is_done": true
}
],
"notam_briefing": true,
"operational_flight_plan": true,
"weather_briefing": true
}
},
"notes": "string",
"roster_detail": {
"doctor": [
{
"id": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
],
"helper": [
{
"id": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
],
"other_person": [
{
"email": "guest@example.com",
"mobile_phone": "+43-1234",
"name": "Guest Person"
}
],
"pilot": [
{
"co_pilot": true,
"crew_type": "main",
"examiner": true,
"flight_instructor": true,
"id": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d",
"line_checker": true,
"supervisor": true
}
],
"rescuer": [
{
"id": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
]
}
},
"type": "takeover_create"
}
}
```
### Response takeover detail
```json
{
"data": {
"type": "takeover",
"id": "019e8713-aa19-7fb7-a510-a0332dc69697",
"attributes": {
"id": "019e8713-aa19-7fb7-a510-a0332dc69697",
"flight_id": "019e8afa-e602-716c-8d2a-b6b11a89caf0",
"reserve_ac_id": "019e8713-aa19-7fb7-a510-a0332dc69698",
"flight_inspection_id": "019e8713-aa19-7fb7-a510-a0332dc69699",
"shift_start": "05:00:00",
"shift_end": "13:00:00",
"base_id": "019d61db-53c6-7280-87ec-e65f205c6d3b",
"base": {
"id": "019d61db-53c6-7280-87ec-e65f205c6d3b",
"name": "Base Name"
},
"duty_date": "2026-06-03",
"helicopter_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"helicopter": {
"id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"identifier": "D-HYAR",
"designation": "AIRBUS H145",
"type": "H145",
"aog": false,
"is_active": true
},
"notes": "string",
"roster_detail": {
"pilot": [],
"doctor": [],
"rescuer": [],
"helper": [],
"other_person": []
},
"inspection": {
"before": {},
"prepare": {},
"inspection_date": "2026-06-03"
}
}
}
}
```
## Notes
- `takeover` request tidak memakai `shift_date` dan `shift_time`.
- `flights/get-all` menampilkan step `takeover`, `mission`, dan `after_flight_inspection` kalau record after inspection sudah ada.
- `mission.step` ditampilkan sebagai `step: 2`.
- `complete` selalu ada di response step; kalau belum selesai nilainya `false`.
- `mission_type.completed` masih angka, karena itu hitungan draft/completed, bukan boolean.

96
docs/AUTH_SSO_FLOW.md Normal file
View File

@@ -0,0 +1,96 @@
# AUTH SSO FLOW (Frontend)
## 1) New SSO Login Flow
1. Frontend calls `GET /api/v1/auth/microsoft/login`.
2. Redirect user to `data.attributes.url`.
3. Provider redirects to backend callback `GET /api/v1/auth/microsoft/callback?code=...&state=...`.
4. Backend checks existing SSO mapping (`provider=microsoft` + `provider_subject`).
5. Result:
- Mapping + user exists: login success, cookies/session issued.
- Mapping not found: login rejected (`401`).
- Mapping exists but user missing: login rejected (`404`).
## 2) Important Rule
SSO **does not create new account automatically**. User must already exist and be linked first.
## 3) Link SSO (for logged-in existing user)
Endpoint: `POST /api/v1/auth/sso/link`
Auth: required (same auth cookie/session as normal login).
Request:
```json
{
"data": {
"type": "auth_sso_link",
"attributes": {
"provider": "microsoft",
"code": "<microsoft_authorization_code>"
}
}
}
```
Success `200`:
```json
{
"data": {
"type": "auth_sso_link",
"attributes": {
"provider": "microsoft",
"provider_subject": "00000000-0000-0000-0000-000000000000",
"email": "user@example.com"
}
}
}
```
## 4) Unlink SSO
Endpoint: `DELETE /api/v1/auth/sso/unlink`
Auth: required.
Request:
```json
{
"data": {
"type": "auth_sso_unlink",
"attributes": {
"provider": "microsoft"
}
}
}
```
Success `200`:
```json
{
"data": {
"type": "auth_sso_unlink",
"attributes": {
"provider": "microsoft",
"unlinked": true
}
}
}
```
## 5) Endpoint Summary
- `GET /api/v1/auth/microsoft/login`
- `GET /api/v1/auth/microsoft/callback`
- `POST /api/v1/auth/sso/link`
- `DELETE /api/v1/auth/sso/unlink`
## 6) Error Handling (Frontend)
Handle these API errors explicitly:
- `401 Unauthorized`
- `403 Forbidden`
- `404 user not found`
- `401 sso not linked`
- `409 sso already linked`
- `409 sso linked to another user`
Recommended behavior:
- `401 sso not linked`: show message to login manually first, then link SSO in account settings.
- `409` errors on link: inform user mapping conflict and stop retry loop.
- `404 user not found`: force logout and ask user to re-authenticate.

379
docs/aog-mcf-nsr-flow.md Normal file
View File

@@ -0,0 +1,379 @@
# Panduan Status Helikopter: AOG, MCF, NSR & Cara Menerbangkannya Lagi
> Dokumen ini menjelaskan **kapan sebuah helikopter berhenti boleh terbang**, **kenapa**, dan **langkah apa yang harus dilakukan supaya boleh terbang lagi** — lengkap dengan endpoint yang dipakai di tiap langkah.
>
> Ditulis supaya bisa dimengerti baik oleh tim operasional maupun developer. Bagian teknis (endpoint, field) ada di tiap langkah, tapi alurnya diceritakan dulu dengan bahasa sederhana.
>
> Semua endpoint berawalan `/api/v1` dan butuh login.
---
## 1. Istilah Penting (versi gampang)
Sebelum masuk alur, kenali dulu pemainnya:
| Istilah | Bahasa gampangnya |
|---|---|
| **Complaint** | Laporan kerusakan/keluhan pada helikopter. "Ada yang rusak nih." |
| **MEL** (Minimum Equipment List) | Aturan: kerusakan ini boleh ditunda berapa lama sebelum pesawat **wajib** berhenti. Ada kelas A/B/C/D (makin besar = makin lama boleh ditunda). |
| **NON-MEL** | Kerusakan yang **tidak boleh ditunda sama sekali** → pesawat langsung berhenti. |
| **AOG** (Aircraft On Ground) | Status "pesawat dikandangkan, **tidak boleh terbang**". |
| **NSR** (Non-Safety Release) | Keputusan: "kerusakan ini aman ditunda, **boleh terbang dulu**" walau belum diperbaiki. |
| **Action Taken** | Tindakan perbaikan sementara yang dicatat, supaya pesawat boleh terbang lagi — tapi **belum dianggap selesai resmi**. |
| **EASA Release** (CRS Part-145) | Surat resmi "**pesawat sudah diperbaiki & layak terbang**". Inilah yang benar-benar **menutup** kerusakan. |
| **MCF** (Maintenance Check Flight) | Penerbangan uji setelah perbaikan besar. Selama wajib MCF, pesawat berstatus khusus. |
| **Fleet Status** | Data kondisi tiap helikopter: jadwal perawatan, jam terbang, status, dll. |
**Inti yang paling sering bikin bingung** (akan diulang lagi nanti):
> **Boleh terbang lagi** ≠ **kerusakan selesai.**
> - Pesawat bisa **boleh terbang lagi** lewat *Action Taken* atau *NSR* — tapi laporannya **masih terbuka**.
> - Kerusakan baru **benar-benar selesai (ditutup)** ketika **EASA Release ditandatangani (sign)** — bukan sekadar dibuat.
---
## 2. Status Helikopter & Urutannya
Helikopter selalu ada di salah satu dari 4 status ini. Status **dihitung otomatis** dari kondisi data (bukan diisi manual):
| Status | Artinya |
|---|---|
| `mcf` | Wajib penerbangan uji (Maintenance Check Flight) dulu |
| `aog` | Dikandangkan, tidak boleh terbang |
| `booked` | Sedang dipakai misi |
| `available` | Sehat, siap terbang |
**Kalau beberapa kondisi terjadi bersamaan, yang menang urutannya begini:**
```
MCF > AOG > Booked > Available
```
Contoh: helikopter yang sedang wajib MCF **dan** punya kerusakan AOG → tetap tampil `mcf` (karena MCF paling tinggi). Ini sama persis dengan sistem Wucher lama.
---
## 3. Gambaran Besar: 3 Jalan Sebuah Helikopter Berhenti Terbang
Ada **3 sebab** helikopter jadi tidak boleh terbang. Tiap sebab punya cara keluar sendiri:
| Sebab berhenti | Jadi status | Cara terbang lagi |
|---|---|---|
| **A. Ada kerusakan** (complaint) | `aog` | EASA Release disign (atau ditunda dulu via Action/NSR) |
| **B. Jadwal perawatan jatuh tempo** (otomatis) | `aog` | Perbarui jadwal/selesai inspeksi → hitung ulang |
| **C. Wajib penerbangan uji** (MCF) | `mcf` | Selesaikan MCF dengan hasil "passed" |
Tiga sebab ini dibahas satu per satu di bawah.
---
## 4. JALAN A — Helikopter Rusak (Complaint → AOG → Perbaikan)
Ini alur paling sering. Ceritanya berurutan:
### Langkah 1 — Catat kerusakannya
Begitu ada kerusakan, buat laporan (complaint).
`POST /complaints/create` (permission `complaint.create`)
```jsonc
{
"data": {
"type": "complaint_create",
"attributes": {
"helicopter_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"description": "Getaran di Engine 2 saat cruise",
"mel_severity": 0, // lihat tabel di Langkah 2
"aircraft_hours_at_report": "1245.3"
}
}
}
```
### Langkah 2 — Tentukan seberapa parah (klasifikasi MEL)
Field `mel_severity` menentukan **kapan pesawat harus berhenti**:
| `mel_severity` | Kelas | Boleh terbang sampai kapan? |
|---|---|---|
| `0` | NON-MEL | **Tidak boleh sama sekali** → AOG seketika |
| `1` | A | Maksimal **1 hari**, lewat itu → AOG |
| `2` | B | Maksimal **3 hari** → AOG |
| `3` | C | Maksimal **10 hari** → AOG |
| `4` | D | Maksimal **120 hari** → AOG |
| *(dikosongkan)* | belum diklasifikasi | Statusnya `pending_classification`, **belum bikin AOG** sampai diklasifikasi |
> Klasifikasi bisa diisi belakangan lewat `PATCH /complaints/update/:id` (field `mel_severity`). Sistem mencatat siapa & kapan mengklasifikasi (audit `mel_classified_by/at`).
Selama kerusakan ini bikin AOG, di Fleet Status detail helikopter akan **muncul** data `complaints` + `easa`. Kalau sudah tidak AOG, dua data itu hilang sendiri.
### Langkah 3 (opsional) — Mau terbang dulu sebelum diperbaiki tuntas?
Kadang pesawat perlu tetap operasi sambil menunggu perbaikan resmi. Ada **2 cara**, dan ini beda fungsi:
**Cara 3a — Action Taken (tindakan sementara sudah dilakukan):**
`PATCH /complaints/update/:id` isi `action_taken`.
```jsonc
{ "data": { "type": "complaint_update", "id": "<complaint_id>",
"attributes": {
"action_taken": "Kencangkan engine mount, cek torsi OK",
"aircraft_hours_at_fix": "1250.1"
} } }
```
→ Pesawat **boleh terbang lagi**, tapi laporan **masih terbuka**.
**Cara 3b — NSR (kerusakan aman ditunda):**
`PATCH /complaints/update/:id` isi `is_nsr: true` (**butuh permission khusus `complaint.nsr`**).
```jsonc
{ "data": { "type": "complaint_update", "id": "<complaint_id>",
"attributes": {
"is_nsr": true,
"nsr_reason": "Dirilis di bawah MEL, defect ditunda"
} } }
```
→ Pesawat **boleh terbang lagi**, laporan **masih terbuka**. Sistem mencatat siapa yang memutuskan NSR (`nsr_decided_by/at`).
> **Beda Action Taken vs NSR:**
> - *Action Taken* = "sudah saya tangani sementara".
> - *NSR* = "belum ditangani, tapi memang **boleh ditunda** dengan aman".
> Keduanya sama-sama bikin pesawat boleh terbang lagi, dan **sama-sama belum menutup laporan**.
>
> Membatalkan NSR: kirim `is_nsr: false` → aturan AOG berlaku lagi.
### Langkah 4 — Tutup kerusakan secara resmi (EASA Release)
Inilah langkah yang **benar-benar menyelesaikan** kerusakan. Tiga sub-langkah:
**4a. Buat surat EASA-nya** (boleh draft / belum lengkap dulu):
`POST /easa-releases/create` (permission `easa_release.create`)
> ⚠️ **Syarat boleh membuat EASA:** semua complaint terbuka helikopter itu **harus sudah di-*Action Taken* atau di-NSR dulu** (Langkah 3). Kalau masih ada yang "mentah", create ditolak (`ErrEASAReleaseComplaintsPendingAction`). Jadi Langkah 3 itu **pintu masuk** ke Langkah 4.
**4b. Lengkapi datanya** (kalau tadi masih draft):
`PATCH /easa-releases/update/:id`
**4c. TANDA TANGAN (sign) — ini momen kerusakan benar-benar ditutup:**
`POST /easa-releases/sign/:id` (permission `easa_release.sign`, **tanpa body** — penandatangan = user yang sedang login).
```bash
POST /api/v1/easa-releases/sign/019d6772-471c-7b70-b42a-d94020ef61e4
```
**Sebelum bisa sign, 5 field ini WAJIB terisi:**
| Field | Contoh |
|---|---|
| `easa_date` | `2026-06-16` |
| `easa_ac_hours` | `1245.3` |
| `easa_location` | `LOWI` |
| `easa_wo_no` | `WO-2026-001` |
| `easa_signer_id` | `145.A.50` |
Kalau ada yang kosong → ditolak (`ErrEASAReleaseIncompleteForSign`) beserta daftar `missing_sign_fields`. Aturannya tegas: **EASA yang belum lengkap tidak boleh dianggap selesai.**
> **Jalan pintas:** kalau datanya sudah lengkap dari awal, kamu bisa langsung sign **saat create** — tambahkan `"sign": true` di body create. (Catatan: flag `sign` ini **hanya ada di create**, tidak ada di update.)
**Begitu sign berhasil, otomatis terjadi:**
1. Surat EASA tercatat `signed_at` + `signed_by`.
2. **Semua complaint terbuka** helikopter itu ditutup → statusnya jadi `serviced`.
3. Karena tidak ada lagi kerusakan yang menggantung → **AOG hilang**, data `complaints`/`easa` di Fleet Status ikut hilang.
4. Tercatat di riwayat (history): "Release to Service (EASA signed)".
### Ringkasan Jalan A (urut)
```
Rusak → buat complaint → klasifikasi MEL
→ (opsional) Action Taken / NSR ➜ boleh terbang, laporan MASIH terbuka
→ EASA create → EASA lengkapi → EASA SIGN ✅ ➜ laporan DITUTUP, AOG hilang
```
---
## 5. JALAN B — Jadwal Perawatan Jatuh Tempo (AOG Otomatis)
### Apa ini?
Tiap helikopter punya jadwal inspeksi di Fleet Status (mis. inspeksi tiap 100 jam, atau jatuh tempo di tanggal tertentu). Kalau **jam terbang** atau **tanggal** sudah melewati batas, sistem **otomatis** men-AOG-kan pesawat — tanpa perlu ada yang lapor.
### Cara masuk AOG
Diatur di Fleet Status, tiap jadwal punya:
- `due` = batas normal (boleh berupa **jam**, mis. `1200`, atau **tanggal**, mis. `2026-09-01`)
- `ext` = batas perpanjangan (kalau diisi, ini yang dipakai menggantikan `due`)
Saat jam/tanggal aktual melewati batas → status jadi `aog` dengan keterangan otomatis (`AOG: ... next due reached`).
> Engine yang tidak aktif di helikopter (flag `NR1`/`NR2` = false) **dilewati** — jadwal engine itu tidak ikut bikin AOG.
### Kapan sistem menghitung ulang?
AOG jenis ini **disimpan**, jadi perlu pemicu untuk berubah:
- Saat data penerbangan (flight data) dibuat/diubah/dihapus.
- Saat Fleet Status helikopter itu di-update.
- **Otomatis tiap 1 jam** (cron) — supaya AOG berbasis tanggal tetap nyala walau tidak ada aktivitas.
### Cara terbang lagi
Perbarui jadwalnya supaya tidak lagi lewat tempo (mis. setelah inspeksi selesai, isi `due`/`ext` yang baru):
`PATCH /fleet-status/update/:id`
```jsonc
{ "data": { "type": "fleet_status_update", "id": "<fleet_status_id>",
"attributes": { "maintenance_schedules": [
{ "inspection_type": "100H", "due": "1400", "ext": "" }
] } } }
```
Pada perhitungan ulang berikutnya, AOG otomatis ini dilepas. (Catatan: sistem hanya melepas AOG yang dibuatnya sendiri secara otomatis; AOG manual tidak disentuh.)
---
## 6. JALAN C — Wajib Penerbangan Uji (MCF)
### Apa ini?
Setelah pekerjaan perawatan besar, helikopter kadang wajib menjalani **penerbangan uji (MCF)** dulu sebelum dianggap normal. Selama wajib MCF, statusnya `mcf` (mengalahkan AOG).
### Langkah 1 — Tandai wajib MCF (cukup minimal)
`POST /mcf/create` (permission `mcf.create`) — body cukup `helicopter_id` saja (+ `notes` opsional). Tujuannya hanya menandai pesawat ini wajib MCF.
```jsonc
{ "data": { "type": "mcf_create",
"attributes": {
"helicopter_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"notes": "Wajib MCF setelah kerja engine mount"
} } }
```
### Langkah 2 — Selesaikan MCF setelah terbang uji
`POST /mcf/complete/:helicopter_id` (permission `mcf.complete`) — **pakai helicopter_id**, bukan id MCF. Wajib isi semua field hasil uji:
```jsonc
{ "data": { "type": "mcf_complete",
"attributes": {
"af_hours": "1245.3",
"landing_count": 3,
"utc_time": "2026-06-17T08:30:00Z", // format RFC3339
"date": "2026-06-17", // format YYYY-MM-DD
"result": "passed", // passed atau failed
"notes": "Getaran dalam batas normal"
} } }
```
**Hasilnya menentukan:**
| `result` | Yang terjadi |
|---|---|
| `passed` | MCF beres → pesawat **lepas dari status MCF** (kembali ke status lain sesuai kondisi) |
| `failed` | Pesawat **tetap MCF**, hasil gagal dicatat ke riwayat → ulangi sampai `passed` |
> Catatan: kalau tidak ada MCF terbuka untuk helikopter itu, complete ditolak (`409`).
---
## 7. Melihat Riwayat (History)
Semua kejadian penting di atas — dari kerusakan dilaporkan, diklasifikasi, NSR, perbaikan, EASA disign, hasil MCF, sampai AOG otomatis nyala/mati — **tercatat otomatis** dalam satu garis waktu (timeline) per helikopter. Berguna untuk audit: *siapa* melakukan *apa* dan *kapan*.
### Di mana lihatnya?
`GET /fleet-status/history/:helicopter_id` (permission `flight.read`)
```bash
GET /api/v1/fleet-status/history/019d6771-5fb5-7337-837f-bc5ed85181a1?page=1&page_size=20
```
- Diurutkan **terbaru di atas**.
- Mendukung halaman: `page` (default 1) & `page_size` (default 20).
- Timeline ini **gabungan dua sumber**: catatan perawatan yang diselesaikan (service log) + semua kejadian aksi (complaint, EASA, MCF, AOG).
### Kejadian apa saja yang tercatat?
| `action` | Tercatat ketika… |
|---|---|
| `complaint_filed` | Laporan kerusakan dibuat |
| `mel_classified` | Kerusakan diklasifikasi MEL (A/B/C/D atau NON-MEL) |
| `nsr_done` | NSR diberikan (boleh terbang walau defect terbuka) |
| `nsr_cancelled` | NSR dibatalkan |
| `action_taken` | Tindakan perbaikan sementara dicatat |
| `easa_created` | Surat EASA dibuat (draft) |
| `easa_signed` | **EASA ditandatangani → kerusakan ditutup** |
| `mcf_result` | Hasil MCF diisi (`passed`/`failed`) |
| `aog_set` | AOG otomatis dinyalakan (jadwal jatuh tempo) |
| `aog_cleared` | AOG otomatis dilepas |
| `serviced` | Inspeksi/perawatan diselesaikan |
### Bentuk responsnya
Tiap baris timeline berisi: kapan, jenisnya, aksinya, pesan, dan **siapa pelakunya** (nama langsung tersedia di `actor_name`).
```jsonc
{
"data": [
{
"at": "2026-06-17T10:15:00Z",
"entity_type": "easa_release",
"entity_id": "019d6772-471c-7b70-b42a-d94020ef61e4",
"action": "easa_signed",
"message": "Release to Service (EASA signed)",
"actor": "019e91e6-2908-7f37-93e6-015ed1b7587d",
"actor_name": "John Doe"
},
{
"at": "2026-06-17T09:40:00Z",
"entity_type": "complaint",
"action": "action_taken",
"message": "Action taken — Kencangkan engine mount, cek torsi OK",
"actor_name": "Jane Tech"
}
],
"meta": { "page_number": 1, "page_size": 20, "total": 12 }
}
```
> Selain timeline ini, daftar **kerusakan per helikopter** (beserta status terkini, NSR, action, dan EASA terkait) bisa dilihat lewat `GET /complaints/get-by-helicopter/:helicopter_id`, dan kondisi terkini helikopter (status + complaint + EASA aktif) lewat `GET /fleet-status/get-by-helicopter/:helicopter_id`.
---
## 8. Tabel Endpoint Ringkas
| Mau apa | Endpoint | Permission |
|---|---|---|
| Lapor kerusakan | `POST /complaints/create` | `complaint.create` |
| Klasifikasi MEL / Action Taken / NSR | `PATCH /complaints/update/:id` | `complaint.update` (+`complaint.nsr` utk NSR) |
| Lihat kerusakan per helikopter | `GET /complaints/get-by-helicopter/:helicopter_id` | `complaint.read` |
| Buat surat EASA | `POST /easa-releases/create` | `easa_release.create` |
| Lengkapi EASA | `PATCH /easa-releases/update/:id` | `easa_release.update` |
| **Tanda tangan EASA (tutup kerusakan)** | `POST /easa-releases/sign/:id` | `easa_release.sign` |
| Tandai wajib MCF | `POST /mcf/create` | `mcf.create` |
| **Selesaikan MCF** | `POST /mcf/complete/:helicopter_id` | `mcf.complete` |
| Atur jadwal perawatan & jam | `POST /fleet-status/create` · `PATCH /fleet-status/update/:id` | `flight.create` / `flight.update` |
| Lihat status + complaint + EASA | `GET /fleet-status/get-by-helicopter/:helicopter_id` | `flight.read` |
| Lihat riwayat helikopter | `GET /fleet-status/history/:helicopter_id` | `flight.read` |
---
## 9. TanyaJawab Cepat (FAQ)
**T: Kerusakan dianggap selesai saat EASA dibuat atau saat disign?**
J: Saat **disign**. Membuat (create) atau melengkapi (update) EASA **belum** menutup apa pun. Penutupan complaint terjadi tepat di endpoint `sign`.
**T: Bedanya Action Taken, NSR, dan EASA Sign?**
J:
- **Action Taken** & **NSR** → bikin pesawat **boleh terbang lagi**, tapi laporan **masih terbuka**.
- **EASA Sign** → **menutup** laporan secara resmi (status `serviced`).
**T: Kalau cuma isi Action Taken, kapan laporannya hilang dari daftar AOG?**
J: AOG-nya langsung hilang begitu Action Taken diisi (pesawat boleh terbang). Tapi laporannya **tetap tercatat terbuka** sampai EASA disign.
**T: Kenapa saya tidak bisa membuat EASA?**
J: Kemungkinan masih ada complaint yang belum di-Action/NSR. Selesaikan Langkah 3 dulu untuk tiap complaint terbuka.
**T: Helikopter wajib MCF tapi juga ada kerusakan AOG, tampil yang mana?**
J: Tampil `mcf`, karena MCF prioritas tertinggi.
**T: AOG karena jadwal jatuh tempo kok tidak langsung hilang setelah saya update jadwal?**
J: AOG jenis ini dihitung ulang saat ada flight data baru, update fleet status, atau otomatis tiap 1 jam. Tunggu pemicu berikutnya.
**T: Di mana saya bisa lihat riwayat apa saja yang sudah terjadi pada satu helikopter?**
J: Di `GET /fleet-status/history/:helicopter_id` — timeline lengkap (terbaru di atas) berisi siapa melakukan apa & kapan. Lihat §7.
---
## 10. Catatan untuk Developer
- **AOG karena kerusakan** dihitung *saat dibaca* (real-time dari complaint terbuka) → berubah instan, tidak perlu job.
- **AOG karena jadwal** *disimpan* di helikopter → perlu pemicu (flight data / update fleet / cron 1 jam). Hanya AOG otomatis yang dilepas otomatis; AOG manual tidak.
- **MCF mengalahkan AOG** dalam prioritas status.
- **Penutupan kerusakan hanya lewat EASA sign** — `action_taken` & `is_nsr` hanya mengangkat larangan terbang, tidak menutup laporan.
- **Masa tenggang MEL dihitung dalam HARI** (A=1, B=3, C=10, D=120) di `internal/domain/complaint/model.go` fungsi `MELDeadline` (`* 24 * time.Hour`).

49
docs/audit-log.md Normal file
View File

@@ -0,0 +1,49 @@
# Audit Log
## Tujuan
- Menyimpan jejak request API dan event penting service ke tabel `audit_logs`.
- Bersifat append-only.
- Tidak menyimpan secret sensitif (password, raw token).
## Desain
- Semua route di bawah `/api/*` dicatat otomatis oleh middleware `AuditMiddleware`.
- Penyimpanan ke DB dilakukan async melalui `AuditLogService` (buffered queue + worker) agar request API tetap cepat.
- Jika queue penuh, request tetap lanjut (fail-open) dan sistem menulis warning log.
## Schema Ringkas (`audit_logs`)
- `id` (BINARY(16), PK)
- `request_id` (indexed)
- `actor_user_id` (nullable, indexed)
- `layer` (`api` / `service`)
- `action` (indexed)
- `method`, `path`, `status_code`, `success`
- `ip`, `user_agent`
- `message`
- `metadata` (JSON)
- `created_at`
## Helper Pemakaian di Service
Gunakan helper yang sudah ada di `AuthService`:
```go
s.logServiceAudit(ctx, "auth.reset_password.consume", userID, true, "password_reset_success", map[string]any{
"reason": "manual_reset",
})
```
Untuk service lain, inject `*service.AuditLogService` lalu panggil:
```go
auditSvc.Log(ctx, service.AuditLogInput{
Layer: "service",
Action: "your.service.action",
ActorUserID: userID,
Success: true,
Message: "done",
Metadata: map[string]any{"key": "value"},
})
```

272
docs/auth-flow.md Normal file
View File

@@ -0,0 +1,272 @@
# Auth Flow
This document describes the recommended authentication flows in Wucher. All API responses follow **JSON:API**.
## 1) Email/Password Registration
### 1.1 Register
`POST /api/v1/auth/register`
Notes:
- The user role is assigned based on `AUTH_DEFAULT_ROLE`.
- For production, you can disable this endpoint by setting `AUTH_DISABLE_REGISTER=true`.
Request:
```json
{
"data": {
"type": "auth_register",
"attributes": {
"email": "user@example.com",
"password": "StrongP@ssw0rd",
"first_name": "John",
"last_name": "Doe",
"mobile_phone": ""
}
}
}
```
Response: `201 Created` with user resource.
### 1.2 Verify Email
User receives a verification link in email:
`GET /api/v1/auth/verify-email?token=...`
Response: `200 OK` with `{ verified: true }`.
### 1.3 Login
`POST /api/v1/auth/login`
If TOTP **is not enabled**, response is `200 OK` and cookies are set:
- `wucher_at` (access)
- `wucher_rt` (refresh)
If TOTP **is enabled**, response is `202 Accepted` with `challenge_token`.
### 1.4 Forgot Password
`POST /api/v1/auth/forgot-password`
Request:
```json
{
"data": {
"type": "auth_forgot_password",
"attributes": {
"email": "user@example.com"
}
}
}
```
Response is always `200 OK` with a generic message:
`If the account exists, a password reset link has been sent.`
### 1.5 Reset Password
`POST /api/v1/auth/reset-password`
Request:
```json
{
"data": {
"type": "auth_reset_password",
"attributes": {
"token": "<token>",
"new_password": "StrongPassphrase123!",
"confirm_password": "StrongPassphrase123!"
}
}
}
```
Success response: `200 OK` with message instructing the user to log in again.
Invalid/expired/used token response: `400 Bad Request` with:
`This reset link is invalid or expired.`
### 1.6 Security PIN for Sensitive Actions
Security PIN endpoints:
- `POST /api/v1/auth/pin/setup`
- `POST /api/v1/auth/pin/verify`
- `POST /api/v1/auth/pin/change`
- `POST /api/v1/auth/pin/forgot`
- `POST /api/v1/auth/pin/reset`
For `POST /api/v1/auth/pin/reset`:
- If `method=password`, send the reset `token` from the email link plus `password`.
- If `method=microsoft`, send only `reauth_token` from Microsoft re-auth.
Sensitive actions require header:
- `X-PIN-Verification-Token: <one-time-token>`
`auth/pin/verify` menggunakan `action` berupa UUID opaque (transaction ID), bukan permission key seperti `user.delete`.
Detailed flow + full payload examples:
- See [docs/security-pin.md](security-pin.md)
## 2) TOTP Setup (Enable 2FA)
### 2.1 Setup
`POST /api/v1/auth/totp/setup`
Request:
```json
{
"data": {
"type": "auth_totp_setup",
"attributes": {
"user_id": "<uuid>",
"email": "user@example.com"
}
}
}
```
Response: `200 OK` with `secret` and `otpauth_url`.
> TOTP is **not** enabled yet (pending).
### 2.2 Confirm
`POST /api/v1/auth/totp/confirm`
Request:
```json
{
"data": {
"type": "auth_totp_confirm",
"attributes": {
"user_id": "<uuid>",
"code": "123456"
}
}
}
```
Response: `200 OK` and TOTP becomes **enabled**.
## 3) TOTP Login (2FA)
### 3.1 Login
`POST /api/v1/auth/login`
Response: `202 Accepted` with `challenge_token`.
### 3.2 Verify
`POST /api/v1/auth/totp/verify`
Request:
```json
{
"data": {
"type": "auth_totp_verify",
"attributes": {
"challenge_token": "<token>",
"code": "123456"
}
}
}
```
Response: `200 OK` and cookies are set.
## 4) Microsoft Entra SSO
### 4.1 Start Login
`GET /api/v1/auth/microsoft/login`
Response: `200 OK` with JSON:API body (`type: auth_microsoft_url`) containing `url` and `redirect_url`
### 4.2 Callback
Microsoft redirects to:
`GET /api/v1/auth/microsoft/callback?code=...&state=...`
Backend behavior:
- If SSO mapping (`provider + provider_subject`) is linked to an existing user, login succeeds.
- If mapping is missing, login is rejected (`401 SSO not linked`).
- If mapping exists but user record is missing, login is rejected (`404 user not found`).
- No auto-register on SSO callback.
On success, backend sets cookies and may redirect to:
`AUTH_SSO_SUCCESS_REDIRECT`
### 4.3 Link SSO (authenticated user)
`POST /api/v1/auth/sso/link`
Body:
```json
{
"data": {
"type": "auth_sso_link",
"attributes": {
"provider": "microsoft",
"code": "<microsoft_authorization_code>"
}
}
}
```
Rules:
- 1 user only 1 SSO mapping.
- Same SSO account cannot be linked to another user.
### 4.4 Unlink SSO (authenticated user)
`DELETE /api/v1/auth/sso/unlink`
Body:
```json
{
"data": {
"type": "auth_sso_unlink",
"attributes": {
"provider": "microsoft"
}
}
}
```
## 5) WebAuthn (Passkey)
### 5.1 Register Credential (Authenticated User)
1. `POST /api/v1/auth/webauthn/register/begin` (auth required)
2. Browser executes `navigator.credentials.create(...)` using `public_key` from response
3. `POST /api/v1/auth/webauthn/register/finish` with `challenge_token` + `credential`
### 5.2 Login with WebAuthn
1. `POST /api/v1/auth/webauthn/login/begin` with email
2. Browser executes `navigator.credentials.get(...)` using `public_key` from response
3. `POST /api/v1/auth/webauthn/login/finish` with `challenge_token` + `credential`
4. On success, backend sets auth cookies.
## 6) Refresh & Logout
### Refresh
`POST /api/v1/auth/refresh`
Uses refresh cookie to issue new tokens.
### Logout
`POST /api/v1/auth/logout`
Revokes refresh token and clears cookies.
Frontend note:
- Use `credentials: 'include'` on requests that depend on auth cookies.
- Keep app logout local-only; do not redirect to Microsoft logout from this endpoint.
- Microsoft front-channel logout is handled separately at `/api/v1/auth/microsoft/front-channel-logout` and is designed to work in an iframe.
## Notes
- Access token is stored in HttpOnly cookie: `AUTH_JWT_ACCESS_COOKIE`
- Refresh token is stored in HttpOnly cookie: `AUTH_JWT_REFRESH_COOKIE`
- Cookies are configured by `AUTH_JWT_COOKIE_*` envs
- Microsoft SSO sessions use `SameSite=None; Secure` so front-channel iframe logout can receive cookies.
- Access token TTL is controlled by `AUTH_JWT_ACCESS_TTL` (default `10m`)
- Refresh token TTL is controlled by `AUTH_JWT_REFRESH_TTL` (default `1h`)
- TOTP challenge tokens are stored server-side in MySQL-backed runtime storage for `AUTH_TOTP_CHALLENGE_TTL`
- Password-reset tokens are stored in MySQL as SHA-256 hashes (raw token is never stored)
- Password-reset tokens are single-use and expire by `AUTH_PASSWORD_RESET_TTL` (default 20m)
- Forgot-password endpoint has per-IP and per-email throttling
- Login (including SSO callback and post-TOTP token issue) bumps per-user session version, so a new login invalidates previous active sessions
- Successful password reset revokes active sessions by bumping per-user session version
- PIN can be blocked after max failed attempts; use `POST /api/v1/auth/pin/request-reset` (auth required) to request reset email for blocked accounts
- WebAuthn challenge token is single-use, stored server-side, and expires by `AUTH_WEBAUTHN_CHALLENGE_TTL`.
- WebAuthn credential verification enforces RP ID + RP origin checks from `AUTH_WEBAUTHN_RP_ID` and `AUTH_WEBAUTHN_RP_ORIGINS`.

View File

@@ -0,0 +1,146 @@
# Base Contact Roles Frontend Flow (English)
This document explains the frontend flow for contact assignment in the **Bases** module:
- `responsible_flight_rescuer_contact_ids` (multi-contact)
- `chief_physician_in_charge_contact_ids` (multi-contact)
Contact source is always based on **contact id (`users.id`)**.
## 1) Data Source for Contact Dropdowns
Get contact candidates from contacts API:
- `GET /api/v1/contacts/get-all?role=air_rescuer&limit=200`
Minimum fields for FE:
- `id` (resource id) => send this in base payload
- `attributes.first_name`, `attributes.last_name`, `attributes.short_name` (display label)
- optional: `attributes.is_active`
UI suggestion:
- Use 2 separate multi-select components:
- Responsible Flight Rescuer
- Chief Physician In Charge
- The same person may exist in both lists if business rules allow it.
## 2) Create Base Flow
Endpoint:
- `POST /api/v1/bases/create`
Example payload:
```json
{
"data": {
"type": "base",
"attributes": {
"base": "Lude",
"base_category": "regular",
"responsible_flight_rescuer_contact_ids": [
"0196a111-1111-7abc-9def-111111111111",
"0196a222-2222-7abc-9def-222222222222"
],
"chief_physician_in_charge_contact_ids": [
"0196a333-3333-7abc-9def-333333333333"
]
}
}
}
```
Backend behavior:
- each id must be a valid UUID and exist in `users`
- invalid UUID / missing user id => request fails (`4xx`)
## 3) Update Base Flow
Endpoint:
- `PATCH /api/v1/bases/update/{uuid}`
Payload to update assignment only:
```json
{
"data": {
"type": "base_update",
"attributes": {
"responsible_flight_rescuer_contact_ids": [
"0196a111-1111-7abc-9def-111111111111"
],
"chief_physician_in_charge_contact_ids": []
}
}
}
```
Important notes:
- update uses **replace** semantics per base (not append)
- always submit the final arrays from current UI state
- send `[]` to clear all assignments for that role
## 4) Load Base Detail into Edit Form
Endpoint:
- `GET /api/v1/bases/get/{uuid}`
Response fields used by FE:
- `data.attributes.responsible_flight_rescuer_contact_ids`
- `data.attributes.chief_physician_in_charge_contact_ids`
- `data.attributes.responsible_flight_rescuers` (id + first_name + last_name)
- `data.attributes.chief_physicians_in_charge` (id + first_name + last_name)
FE flow:
1. Load base detail.
2. Load contact options for dropdown.
3. Map selected ids to option objects for selected state.
4. Use detailed arrays to show human-readable names immediately.
## 5) Show Base Roles in Contacts Module
Contacts API now includes:
- `attributes.base_roles` (array)
Item format:
```json
{
"base_id": "0196b111-1111-7abc-9def-111111111111",
"base_name": "Lude",
"role_code": "responsible_flight_rescuer"
}
```
Affected endpoints:
- `GET /api/v1/contacts/get-all`
- `GET /api/v1/contacts/get-all/dt`
- `GET /api/v1/contacts/{user_id}`
Suggested label mapping in UI:
- `responsible_flight_rescuer` => `Responsible Flight Rescuer`
- `chief_physician_in_charge` => `Chief Physician In Charge`
## 6) UX Recommendations
- In contact detail page, group `base_roles` by `base_name`.
- In contact list, show compact chips, e.g.:
- `Lude (Responsible Flight Rescuer)`
- `Berlin (Chief Physician In Charge)`
- For large lists, show only first N chips + `+X more`.
## 7) Minimum Error Handling
- If base submit fails because UUID validation fails:
- show general message: `Invalid contact assignment`.
- If contact id no longer exists:
- show message: `Some contacts are no longer available, please refresh`.
- After successful create/update:
- refresh base detail to keep assignment state synced with backend.
## 8) Compatibility Note
For backward compatibility, both ID arrays and detailed name arrays are returned:
- `responsible_flight_rescuer_contact_ids`
- `responsible_flight_rescuers`
- `chief_physician_in_charge_contact_ids`
- `chief_physicians_in_charge`
Recommended FE usage:
- Use `..._contact_ids` for submit payload.
- Use detailed arrays for display names.

View File

@@ -0,0 +1,980 @@
{
"info": {
"name": "Wucher",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Role",
"item": [
{
"name": "Create",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/create",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"create"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"attributes\": {\n \"description\": \"Testing.\",\n \"name\": \"role_test\"\n },\n \"type\": \"role\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Delete",
"request": {
"method": "DELETE",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/delete/:uuid",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"delete",
":id"
],
"query": [],
"variable": [
{
"key": "id",
"value": "019c31eb-5750-7c71-b6b9-042282c61bab"
}
]
}
}
},
{
"name": "Get All DT",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/get-all/dt?draw=1&length=5&start",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"get-all",
"dt?draw=1&length=5&start"
],
"query": [
{
"key": "draw",
"value": "1"
},
{
"key": "length",
"value": "5"
},
{
"key": "start",
"value": ""
},
{
"key": "search",
"value": "sta"
}
],
"variable": []
}
}
},
{
"name": "Get All",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/get-all",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"get-all"
],
"query": [],
"variable": []
}
}
},
{
"name": "Get By ID",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/get/:uuid",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"get",
":id"
],
"query": [],
"variable": [
{
"key": "id",
"value": "019c31eb-5750-7c71-b6b9-042282c61bab"
}
]
}
}
},
{
"name": "Update",
"request": {
"method": "PATCH",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/update/:uuid",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"update",
":id"
],
"query": [],
"variable": [
{
"key": "id",
"value": "019c31eb-5750-7c71-b6b9-042282c61bab"
}
]
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"attributes\": {\n \"description\": \"Testing update.\",\n \"name\": \"test_role\"\n },\n \"id\": \"019c31eb-5750-7c71-b6b9-042282c61bab\",\n \"type\": \"role\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Permission",
"item": [
{
"name": "Assign",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/:uuid/permissions/assign-bulk",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
":uuid",
"permissions",
"assign-bulk"
],
"query": [],
"variable": [
{
"key": "uuid",
"value": ""
}
]
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"role_assign_permission_bulk\",\n \"attributes\": {\n \"permission_ids\": [\n \"019c79bd-c7a5-703d-8b7d-cd935e7e5316\",\n \"019c79bd-c7a5-720e-ad50-989d8848e37c\"\n ]\n }\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Get All Permission",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/permissions/get-all",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
"permissions",
"get-all"
],
"query": [],
"variable": []
}
}
},
{
"name": "Permission of Role",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/:uuid/permissions",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
":uuid",
"permissions"
],
"query": [],
"variable": [
{
"key": "uuid",
"value": "019c30e6-1b8a-7714-898a-6e954e3dad33"
}
]
}
}
},
{
"name": "Remove Permission",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/roles/:uuid/permissions/remove-bulk",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"roles",
":uuid",
"permissions",
"remove-bulk"
],
"query": [],
"variable": [
{
"key": "uuid",
"value": "019c30e6-1b8a-7714-898a-6e954e3dad33"
}
]
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"role_remove_permission_bulk\",\n \"attributes\": {\n \"permission_ids\": [\n \"019c79bd-c7a5-7974-ba89-69d52f0f3712\",\n \"019c79bd-c7a5-7339-add2-5a1c900cf1c3\"\n ]\n }\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
}
]
}
]
},
{
"name": "User",
"item": [
{
"name": "Create",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/users/create",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"users",
"create"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"user\",\n \"attributes\": {\n \"email\": \"marcomelandri808@gmail.com\",\n \"first_name\": \"John\",\n \"last_name\": \"Doe\",\n \"mobile_phone\": \"+628123456789\",\n \"role_id\": \"019c30e6-1b8a-7714-898a-6e954e3dad33\"\n }\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Delete",
"request": {
"method": "DELETE",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/users/delete/:uuid",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"users",
"delete",
":uuid"
],
"query": [],
"variable": [
{
"key": "uuid",
"value": "019c79da-8c32-785c-b547-beee3b321e9b"
}
]
}
}
},
{
"name": "Get All DT",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/users/get-all/dt",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"users",
"get-all",
"dt"
],
"query": [],
"variable": []
}
}
},
{
"name": "Get All",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/users/get-all",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"users",
"get-all"
],
"query": [],
"variable": []
}
}
},
{
"name": "Get By UUID",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/users/get/:uuid",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"users",
"get",
":uuid"
],
"query": [],
"variable": [
{
"key": "uuid",
"value": "019c7940-c4c2-7fa4-ae0b-4eeb9e75295b"
}
]
}
}
},
{
"name": "Update",
"request": {
"method": "PATCH",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/users/update/:uuid",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"users",
"update",
":uuid"
],
"query": [],
"variable": [
{
"key": "uuid",
"value": "019c7940-c4c2-7fa4-ae0b-4eeb9e75295b"
}
]
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"user\",\n \"id\": \"019c7940-c4c2-7fa4-ae0b-4eeb9e75295b\",\n \"attributes\": {\n \"first_name\": \"Johnny\",\n \"last_name\": \"Doe\",\n \"mobile_phone\": \"+628123456700\",\n \"role_id\": \"019c30e6-1b8a-7714-898a-6e954e3dad33\"\n }\n }\n}\n",
"options": {
"raw": {
"language": "json"
}
}
}
}
}
]
},
{
"name": "Auth",
"item": [
{
"name": "Auth Me",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/me",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"me"
],
"query": [],
"variable": []
}
}
},
{
"name": "Invite",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/invite",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"invite"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"auth_invite\",\n \"attributes\": {\n \"email\": \"marcomelandri808@gmail.com\",\n \"first_name\": \"John\",\n \"last_name\": \"Doe\",\n \"mobile_phone\": \"+628123456789\",\n \"role_id\": \"019c30e6-1b8a-7714-898a-6e954e3dad33\"\n }\n }\n}\n",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Login",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/login",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"login"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"auth_login\",\n \"attributes\": {\n \"email\": \"marcomelandri808@gmail.com\",\n \"password\": \"StrongP@ssw0rd!\"\n }\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Logout",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/logout",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"logout"
],
"query": [],
"variable": []
}
}
},
{
"name": "Refresh Token",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/refresh",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"refresh"
],
"query": [],
"variable": []
}
}
},
{
"name": "Register",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/register",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"register"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"auth_register\",\n \"attributes\": {\n \"email\": \"innovatexsh@gmail.com\",\n \"password\": \"securePass123\",\n \"first_name\": \"John\",\n \"last_name\": \"Doe\",\n \"mobile_phone\": \"087782553442\"\n }\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "Set New Password",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/set-password",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"set-password"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"type\": \"auth_set_password\",\n \"attributes\": {\n \"token\": \"--TpCbfl1JEpEJq1jr2l1XeFggiAN7Z2k23pwMVrxac\",\n \"password\": \"StrongP@ssw0rd!\"\n }\n }\n}\n",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "SSO",
"item": [
{
"name": "MS Login",
"request": {
"method": "GET",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/microsoft/login",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"microsoft",
"login"
],
"query": [],
"variable": []
}
}
}
]
},
{
"name": "TOTP",
"item": [
{
"name": "TOTP Disable",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/totp/disable",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"totp",
"disable"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"attributes\": {\n \"user_id\": \"019c2e21-d880-7b9a-89ac-3d3d8593502f\"\n },\n \"type\": \"auth_totp_disable\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "TOTP Setup",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/totp/setup",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"totp",
"setup"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"attributes\": {\n \"email\": \"innovatexsh@gmail.com\",\n \"user_id\": \"019c2e21-d880-7b9a-89ac-3d3d8593502f\"\n },\n \"type\": \"auth_totp_setup\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "TOTP Verify Login",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/totp/verify",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"totp",
"verify"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"attributes\": {\n \"challenge_token\": \"Z_2A0lm5IekOiIBPbpiJzpuevFYW6oxEIgdMZjPkcpw\",\n \"code\": \"568754\"\n },\n \"type\": \"auth_totp_verify\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
},
{
"name": "TOTP Verify",
"request": {
"method": "POST",
"header": [],
"auth": {
"type": "noauth"
},
"description": "",
"url": {
"raw": "{{baseUrl}}/api/v1/auth/totp/confirm",
"protocol": "",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"totp",
"confirm"
],
"query": [],
"variable": []
},
"body": {
"mode": "raw",
"raw": "{\n \"data\": {\n \"attributes\": {\n \"code\": \"898427\",\n \"user_id\": \"019c2e21-d880-7b9a-89ac-3d3d8593502f\"\n },\n \"type\": \"auth_totp_confirm\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
}
}
]
}
]
}
],
"variable": [
{
"key": "baseUrl",
"value": "",
"type": "default"
}
]
}

359
docs/contact-create-flow.md Normal file
View File

@@ -0,0 +1,359 @@
# Contact Create Flow (Frontend Guide)
This document explains the full flow to create a contact, send an automatic invite email, and let the invited user set their first password.
## 1) Prerequisites
1. Admin user is already logged in and has an `access token`.
2. Admin user has already configured a Security PIN.
3. Frontend knows the target `role_id` (fetch from `GET /api/v1/roles/get-all`).
## 2) Endpoints Used
1. `POST /api/v1/auth/pin/verify` to get a PIN `action_token`.
2. `POST /api/v1/contacts/create` to create the contact (auto invite enabled).
3. `POST /api/v1/auth/set-password` to set the first password from invite token.
## 3) Detailed Flow
### Step A - Verify PIN for contact create action
Action ID for contact create:
- `2a59e8b4-5a72-4be3-b8fc-4f49941181c3`
Request:
```json
{
"data": {
"type": "auth_pin_verify",
"attributes": {
"pin": "123456",
"action": "2a59e8b4-5a72-4be3-b8fc-4f49941181c3"
}
}
}
```
Success response:
```json
{
"data": {
"type": "auth_pin_verify",
"attributes": {
"verified": true,
"action_token": "<PIN_ACTION_TOKEN>",
"action_token_ttl_ms": 300000
}
}
}
```
Store `action_token`, then pass it to contact create in header:
- `X-PIN-Verification-Token: <PIN_ACTION_TOKEN>`
### Step B - Create Contact (auto invite)
Endpoint:
- `POST /api/v1/contacts/create`
Required headers:
- `Authorization: Bearer <ACCESS_TOKEN>`
- `X-PIN-Verification-Token: <PIN_ACTION_TOKEN>`
- `Content-Type: application/json`
Minimum required body:
- `data.type = contact_<role>_create`
- `attributes.role_id`
- `attributes.email`
- `attributes.first_name`
- `attributes.last_name`
Allowed create types:
- `contact_pilot_create`
- `contact_doctor_create`
- `contact_air_rescuer_create`
- `contact_technician_create`
- `contact_flight_assistant_create`
- `contact_staff_create`
Rules:
- `data.type` must match the role resolved from `attributes.role_id`.
- Only one role-specific profile object is allowed, and it must match `data.type`.
> Note: backend now **automatically sends an invite email** after successful contact creation.
If PIN token is missing/invalid, API returns `202` with PIN verification required error.
### Step C - User clicks invite link from email
Invite email contains a link like:
- `https://frontend-domain/set-password?token=<INVITE_TOKEN>`
This token already carries user identity (email + user id claims) and is single-use.
### Step D - Frontend submits set password
Endpoint:
- `POST /api/v1/auth/set-password`
Request:
```json
{
"data": {
"type": "auth_set_password",
"attributes": {
"token": "<INVITE_TOKEN>",
"password": "StrongP@ssw0rd123"
}
}
}
```
If successful, user can directly log in using `POST /api/v1/auth/login`.
## 4) Contact Create Payloads by Role
## Pilot
```json
{
"data": {
"type": "contact_pilot_create",
"attributes": {
"role_id": "<ROLE_ID_PILOT>",
"email": "pilot1@example.com",
"username": "pilot1",
"first_name": "Marco",
"last_name": "Melandri",
"mobile_phone": "+628123456789",
"timezone": "Asia/Jakarta",
"is_active": true,
"pilot": {
"pilot_category": "regular",
"short_name": "Marco",
"license_no": "PIL-001",
"license_issued_at": "2025-01-01T00:00:00Z",
"license_expired_at": "2028-01-01T00:00:00Z",
"technician_license_no": "TECH-001",
"has_ifr_qualification": true,
"is_chief_pilot": false,
"is_dul": false,
"total_flight_minutes": 12000,
"responsible_pilot_minutes": 5000,
"second_pilot_minutes": 3000,
"double_command_minutes": 1000,
"flight_instructor_minutes": 500,
"night_flight_minutes": 1200,
"ifr_flight_minutes": 800,
"location": "Jakarta",
"postcode": "12950",
"street_line": "Jl. Sudirman No. 1",
"weight_kg": 72.5,
"photo_file_id": "/uploads/contacts/pilot1.jpg"
}
}
}
}
```
## Doctor
```json
{
"data": {
"type": "contact_doctor_create",
"attributes": {
"role_id": "<ROLE_ID_DOCTOR>",
"email": "doctor1@example.com",
"username": "doctor1",
"first_name": "Alicia",
"last_name": "Pratama",
"mobile_phone": "+628123450001",
"timezone": "Asia/Jakarta",
"is_active": true,
"doctor": {
"short_name": "Dr. Alicia",
"medical_license_no": "MED-DOCTOR-001",
"license_issued_at": "2024-01-01T00:00:00Z",
"license_expired_at": "2029-01-01T00:00:00Z",
"specialization": "Emergency Medicine",
"sub_specialization": "Trauma",
"is_chief_physician": false,
"location": "Jakarta",
"postcode": "12950",
"street_line": "Jl. Jenderal Sudirman No. 10",
"photo_file_id": "/uploads/contacts/doctor1.jpg"
}
}
}
}
```
## Air Rescuer
```json
{
"data": {
"type": "contact_air_rescuer_create",
"attributes": {
"role_id": "<ROLE_ID_AIR_RESCUER>",
"email": "airrescuer1@example.com",
"username": "airrescuer1",
"first_name": "Raka",
"last_name": "Wijaya",
"mobile_phone": "+628123450002",
"timezone": "Asia/Jakarta",
"is_active": true,
"air_rescuer": {
"short_name": "Raka",
"is_chief_physician_in_charge": false,
"responsible_flight_rescuer": false,
"location": "Bandung",
"postcode": "40115",
"street_line": "Jl. Merdeka No. 20",
"photo_file_id": "/uploads/contacts/airrescuer1.jpg"
}
}
}
}
```
## Technician
```json
{
"data": {
"type": "contact_technician_create",
"attributes": {
"role_id": "<ROLE_ID_TECHNICIAN>",
"email": "tech1@example.com",
"username": "tech1",
"first_name": "Bima",
"last_name": "Nugraha",
"mobile_phone": "+628123450003",
"timezone": "Asia/Jakarta",
"is_active": true,
"technician": {
"short_name": "Bima",
"license_no": "TECH-7788",
"is_chief_technician": false,
"is_responsible_complaints": true,
"location": "Surabaya",
"postcode": "60231",
"street_line": "Jl. Diponegoro No. 30",
"photo_file_id": "/uploads/contacts/tech1.jpg"
}
}
}
}
```
## Flight Assistant
```json
{
"data": {
"type": "contact_flight_assistant_create",
"attributes": {
"role_id": "<ROLE_ID_FLIGHT_ASSISTANT>",
"email": "fa1@example.com",
"username": "fa1",
"first_name": "Nadia",
"last_name": "Putri",
"mobile_phone": "+628123450004",
"timezone": "Asia/Jakarta",
"is_active": true,
"flight_assistant": {
"short_name": "Nadia",
"location": "Yogyakarta",
"postcode": "55281",
"street_line": "Jl. Kaliurang No. 15",
"photo_file_id": "/uploads/contacts/fa1.jpg"
}
}
}
}
```
## Staff
```json
{
"data": {
"type": "contact_staff_create",
"attributes": {
"role_id": "<ROLE_ID_STAFF>",
"email": "staff1@example.com",
"username": "staff1",
"first_name": "Dina",
"last_name": "Maharani",
"mobile_phone": "+628123450005",
"timezone": "Asia/Jakarta",
"is_active": true,
"staff": {
"short_name": "Dina",
"role_label": "Operations Staff",
"location": "Semarang",
"postcode": "50132",
"street_line": "Jl. Pandanaran No. 8",
"photo_file_id": "/uploads/contacts/staff1.jpg"
}
}
}
}
```
## 5) Example Create Contact Response
```json
{
"data": {
"type": "contact",
"id": "019ce67e-9289-731d-ae4c-0156902aabbc",
"attributes": {
"role_code": "doctor",
"role_name": "doctor",
"email": "doctor1@example.com",
"first_name": "Alicia",
"last_name": "Pratama",
"is_active": true,
"doctor": {
"short_name": "Dr. Alicia",
"medical_license_no": "MED-DOCTOR-001"
},
"invite_sent": true,
"invite_error": ""
}
}
}
```
If invite sending fails (for example SES/SQS config issue):
- contact create is still successful,
- `invite_sent = false`,
- `invite_error` contains the error message.
## 6) Error Cases Frontend Should Handle
1. `202 PIN verification required` on contact create:
- call `/auth/pin/verify` again (Step A), then retry create with `X-PIN-Verification-Token`.
2. `422 Validation error`:
- check payload fields (invalid role_id, invalid email, etc).
3. `400` on create:
- typically business/constraint conflict.
4. `400` on set-password:
- token invalid/expired/used or password does not meet rules.
## 7) Frontend Integration Summary
1. Admin logs in.
2. Fetch `role_id` from roles list.
3. Verify PIN (`/auth/pin/verify` with contact-create action ID).
4. Create contact (`/contacts/create` + `X-PIN-Verification-Token`).
5. Wait for invited user to open invite email.
6. Set-password page reads `token` from query param.
7. Submit `/auth/set-password` with `{ token, password }`.
8. Redirect user to login page.

129
docs/contacts_module.md Normal file
View File

@@ -0,0 +1,129 @@
# Contacts Module (HEMS)
## Endpoint listing
- `GET /api/v1/contacts?role=pilot`
- `GET /api/v1/contacts?role=pilot&pilot_category=freelance`
- `GET /api/v1/contacts?role=pilot&pilot_category=dry_lease`
- `GET /api/v1/contacts?role=doctor`
- `GET /api/v1/contacts?role=air_rescuer`
- `GET /api/v1/contacts?role=technician`
- `GET /api/v1/contacts?role=flight_assistant`
- `GET /api/v1/contacts?role=staff`
## Sensitive action flow (PIN)
1. Client memanggil endpoint sensitif.
2. API balas `202` dan memberi tahu `action` PIN yang dibutuhkan.
3. Client verifikasi PIN ke `/api/v1/auth/pin/verify`.
4. Backend mengembalikan token verifikasi singkat.
5. Client kirim ulang request sensitif dengan header `X-PIN-Verification-Token`.
## Contoh payload
### 1) Create pilot
```json
{
"data": {
"type": "contact_pilot_create",
"attributes": {
"role_id": "<ROLE_ID_PILOT>",
"email": "pilot1@hems.local",
"username": "pilot1",
"first_name": "Marco",
"last_name": "Pilot",
"mobile_phone": "+62811111111",
"timezone": "Asia/Jakarta",
"pilot": {
"pilot_category": "freelance",
"short_name": "M. Pilot",
"license_no": "LIC-P-1001",
"has_ifr_qualification": true,
"total_flight_minutes": 12500,
"location": "Jakarta"
}
}
}
}
```
### 2) Update doctor
```json
{
"data": {
"type": "contact_doctor_update",
"attributes": {
"is_active": true,
"doctor": {
"short_name": "dr. Ana",
"medical_license_no": "DOC-7788",
"specialization": "Anesthesiology",
"is_chief_physician": false
}
}
}
}
```
Rule:
- `data.type` wajib mengikuti role, mis. `contact_pilot_create`, `contact_air_rescuer_create`, `contact_staff_update`.
- Hanya satu object profile role yang boleh dikirim, dan harus sesuai dengan `data.type`.
### 3) PIN verify sebelum aksi sensitif
```json
{
"data": {
"type": "auth_pin_verify",
"attributes": {
"pin": "123456",
"action": "31f8bb88-a95f-43be-8fd8-129e1355d6a5"
}
}
}
```
### 4) Response `202` saat PIN dibutuhkan
```json
{
"data": {
"type": "pin_verification_required",
"attributes": {
"required": true,
"action": "31f8bb88-a95f-43be-8fd8-129e1355d6a5",
"method": "pin",
"status": 202
}
}
}
```
### 5) List response ringkas
```json
{
"data": {
"type": "contacts",
"attributes": {
"items": [
{
"id": "0195f43e-5030-7d2a-8b8a-020063f5ab03",
"role_code": "pilot",
"role_name": "pilot",
"pilot_category": "dry_lease",
"username": "pilot_dl_1",
"email": "pilot.dl1@hems.local",
"first_name": "Rama",
"last_name": "Wijaya",
"short_name": "R. Wijaya",
"license_no": "LIC-P-9988",
"location": "Surabaya",
"mobile_phone": "+62822222222",
"is_active": true
}
],
"meta": {
"total": 1,
"limit": 20,
"offset": 0
}
}
}
}
```

View File

@@ -0,0 +1,187 @@
<h1>Data Migration Wiki</h1>
<p>
This page is the living technical document for migration scope, decisions, limitations, and open tasks.
Update it for every migration wave.
</p>
<h2>Scope</h2>
<ul>
<li>Database schema migrations in <code>migrations/</code>.</li>
<li>Data migration SQL (legacy to current domain model).</li>
<li>Non-DB migration concerns: binary files (PDF/images), queue backlog, manual user actions.</li>
</ul>
<h2>Source of Truth</h2>
<ul>
<li>Versioned DB migrations: <code>migrations/*.sql</code>.</li>
<li>Legacy helicopter import flow:
<ul>
<li><code>migrations/goose/20260414_helicopter_import_staging.sql</code></li>
<li><code>migrations/goose/20260415_helicopter_import_apply.sql</code></li>
<li><code>migrations/scripts/migration_helicopter.sql</code> (deprecated helper note)</li>
</ul>
</li>
<li>Queue migration notes: <code>docs/queue-sqs-migration.md</code>.</li>
<li>File manager APIs and attachment model:
<ul>
<li><code>docs/file-manager-upload.md</code></li>
<li><code>docs/file-manager-attachment.md</code></li>
</ul>
</li>
</ul>
<h2>Implemented Data Migrations</h2>
<ol>
<li>
<strong>20260326_move_responsible_flight_rescuer_to_air_rescuer_profiles.sql</strong>
<ul>
<li>Copies <code>pilot_profiles.responsible_flight_rescuer</code> into <code>air_rescuer_profiles.responsible_flight_rescuer</code>.</li>
<li>Drops old column from <code>pilot_profiles</code>.</li>
</ul>
</li>
<li>
<strong>20260403_file_manager_file_lifecycle_phase1.sql</strong>
<ul>
<li>Normalizes legacy file lifecycle states (<code>pending/uploading</code>) into newer states (<code>validated/processing</code>).</li>
<li>Carries <code>upload_error</code> into <code>failure_reason</code>.</li>
</ul>
</li>
<li>
<strong>20260415_helicopter_import_apply.sql</strong>
<ul>
<li>Imports rows from <code>helicopter_old_import</code> into <code>helicopters</code>.</li>
<li>Maintains old-to-new ID mapping via <code>helicopter_old_id_map</code>.</li>
<li>Explicitly non-reversible (<code>Down</code> keeps data as audit trail).</li>
</ul>
</li>
</ol>
<h2>Known Migration Decisions and Gaps</h2>
<h3>Confirmed "Will Not Migrate" (Current Decision)</h3>
<ol>
<li>
Legacy helicopter columns intentionally ignored:
<ul>
<li><code>counter</code></li>
<li><code>service</code></li>
<li><code>disabled</code></li>
<li><code>visible</code></li>
</ul>
<p>Decision is documented in <code>migrations/scripts/migration_helicopter.sql</code>.</p>
</li>
</ol>
<h3>Confirmed "Not Migrated Yet" (Open)</h3>
<ol>
<li>
Legacy SQS queue backlog
<ul>
<li>Current worker does not consume old SQS backlog.</li>
<li>Required action: drain/discard SQS backlog before/at cutover.</li>
<li>Source: <code>docs/queue-sqs-migration.md</code>.</li>
</ul>
</li>
<li>
Binary files (PDF/images) into new file manager storage
<ul>
<li>Current migrations create file manager tables and attachment relations, but no SQL migration imports binary objects or bulk-inserts legacy metadata into <code>file_files</code>/<code>attachments</code>.</li>
<li>This means legacy documents may need manual re-upload unless a dedicated import job is created.</li>
</ul>
</li>
<li>
Legacy <code>photo_file_id</code> references in contact profile tables
<ul>
<li>Contact profile schemas still include <code>photo_file_id</code> string columns.</li>
<li>No migration currently maps these into <code>attachments</code> + <code>image_attachment_id</code>/<code>foto_attachment_id</code>.</li>
</ul>
</li>
</ol>
<h2>Manual Actions Required at Cutover</h2>
<ol>
<li>Validate which legacy PDFs/images must remain accessible on day 1.</li>
<li>For files without automated import, perform manual re-upload via file manager flow.</li>
<li>Re-link uploaded files to business references (attachments) and to entity-level attachment columns where relevant.</li>
<li>Obtain client sign-off for any files intentionally left unmigrated.</li>
</ol>
<h2>Migration TODO Register</h2>
<table border="1" cellpadding="6" cellspacing="0">
<thead>
<tr>
<th>ID</th>
<th>Item</th>
<th>Type</th>
<th>Status</th>
<th>Owner</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>MIG-001</td>
<td>Decide final strategy for legacy PDFs/images: automated import vs manual re-upload</td>
<td>Decision</td>
<td>Open</td>
<td>TBD</td>
<td>Blocks final cutover checklist</td>
</tr>
<tr>
<td>MIG-002</td>
<td>Produce inventory of all legacy file references and classify critical/non-critical</td>
<td>Analysis</td>
<td>Open</td>
<td>TBD</td>
<td>Include PDFs + photos</td>
</tr>
<tr>
<td>MIG-003</td>
<td>If automation chosen, implement importer to populate <code>file_files</code> and <code>attachments</code> safely</td>
<td>Engineering</td>
<td>Open</td>
<td>TBD</td>
<td>Prefer idempotent batch import with audit log</td>
</tr>
<tr>
<td>MIG-004</td>
<td>Define mapping from legacy contact <code>photo_file_id</code> to new attachment model</td>
<td>Engineering</td>
<td>Open</td>
<td>TBD</td>
<td>Covers pilot/doctor/air rescuer/technician/FA/staff</td>
</tr>
<tr>
<td>MIG-005</td>
<td>Document and execute SQS backlog drain/discard plan before production cutover</td>
<td>Ops</td>
<td>Open</td>
<td>TBD</td>
<td>See queue migration doc</td>
</tr>
<tr>
<td>MIG-006</td>
<td>Create client-facing exception list for data that cannot/will not be migrated</td>
<td>Governance</td>
<td>Open</td>
<td>TBD</td>
<td>Must be approved before go-live</td>
</tr>
</tbody>
</table>
<h2>Cutover Validation Checklist</h2>
<ol>
<li>Run DB migrations in target environment.</li>
<li>Run all required data migration scripts (including helicopter import flow if applicable).</li>
<li>Validate row counts and sample records for migrated domains.</li>
<li>Validate file accessibility for mandatory records (especially PDFs needed operationally).</li>
<li>Validate queue behavior post-cutover (<code>pending</code> to <code>published</code>, no legacy SQS dependency).</li>
<li>Record migration result, exceptions, and follow-up tickets on this page.</li>
</ol>
<h2>Change Log</h2>
<ul>
<li>2026-04-17: Initial wiki created from current migration scripts and docs.</li>
<li>2026-04-17: Added CKEditor-ready HTML version.</li>
</ul>

View File

@@ -0,0 +1,94 @@
# Data Migration Wiki
This page is the living technical document for migration scope, decisions, limitations, and open tasks.
Update it for every migration wave.
For CKEditor copy-paste, use the HTML version: `docs/data-migration-wiki-ckeditor.html`.
## Scope
- Database schema migrations in [`migrations/`](../migrations/).
- Data migration SQL (legacy to current domain model).
- Non-DB migration concerns: binary files (PDF/images), queue backlog, manual user actions.
## Source of Truth
- Versioned DB migrations: [`migrations/*.sql`](../migrations/).
- Legacy helicopter import flow:
- [`migrations/goose/20260414_helicopter_import_staging.sql`](../migrations/goose/20260414_helicopter_import_staging.sql)
- [`migrations/goose/20260415_helicopter_import_apply.sql`](../migrations/goose/20260415_helicopter_import_apply.sql)
- Deprecated helper note: [`migrations/scripts/migration_helicopter.sql`](../migrations/scripts/migration_helicopter.sql)
- Queue migration notes: [`docs/queue-sqs-migration.md`](./queue-sqs-migration.md)
- File manager APIs and attachment model:
- [`docs/file-manager-upload.md`](./file-manager-upload.md)
- [`docs/file-manager-attachment.md`](./file-manager-attachment.md)
## Implemented Data Migrations
1. `20260326_move_responsible_flight_rescuer_to_air_rescuer_profiles.sql`
- Copies `pilot_profiles.responsible_flight_rescuer` into `air_rescuer_profiles.responsible_flight_rescuer`.
- Drops old column from `pilot_profiles`.
2. `20260403_file_manager_file_lifecycle_phase1.sql`
- Normalizes legacy file lifecycle states (`pending/uploading`) into newer states (`validated/processing`) and carries `upload_error` into `failure_reason`.
3. `20260415_helicopter_import_apply.sql`
- Imports rows from `helicopter_old_import` into `helicopters`.
- Maintains old-to-new ID mapping via `helicopter_old_id_map`.
- Explicitly non-reversible (`Down` keeps data as audit trail).
## Known Migration Decisions and Gaps
### Confirmed "Will Not Migrate" (Current Decision)
1. Legacy helicopter columns intentionally ignored:
- `counter`
- `service`
- `disabled`
- `visible`
- Decision is documented in [`migrations/scripts/migration_helicopter.sql`](../migrations/scripts/migration_helicopter.sql).
### Confirmed "Not Migrated Yet" (Open)
1. Legacy SQS queue backlog
- Current worker does not consume old SQS backlog.
- Required action: drain/discard SQS backlog before/at cutover.
- Source: [`docs/queue-sqs-migration.md`](./queue-sqs-migration.md).
2. Binary files (PDF/images) into new file manager storage
- Current migrations create file manager tables and attachment relations, but no SQL migration imports binary objects or bulk-inserts legacy metadata into `file_files`/`attachments`.
- This means legacy documents may need manual re-upload unless a dedicated import job is created.
3. Legacy `photo_file_id` references in contact profile tables
- Contact profile schemas still include `photo_file_id` string columns.
- No migration currently maps these into `attachments` + `image_attachment_id`/`foto_attachment_id`.
## Manual Actions Required at Cutover
1. Validate which legacy PDFs/images must remain accessible on day 1.
2. For files without automated import, perform manual re-upload via file manager flow.
3. Re-link uploaded files to business references (attachments) and to entity-level attachment columns where relevant.
4. Obtain client sign-off for any files intentionally left unmigrated.
## Migration TODO Register
| ID | Item | Type | Status | Owner | Notes |
|---|---|---|---|---|---|
| MIG-001 | Decide final strategy for legacy PDFs/images: automated import vs manual re-upload | Decision | Open | TBD | Blocks final cutover checklist |
| MIG-002 | Produce inventory of all legacy file references and classify critical/non-critical | Analysis | Open | TBD | Include PDFs + photos |
| MIG-003 | If automation chosen, implement importer to populate `file_files` and `attachments` safely | Engineering | Open | TBD | Prefer idempotent batch import with audit log |
| MIG-004 | Define mapping from legacy contact `photo_file_id` to new attachment model | Engineering | Open | TBD | Covers pilot/doctor/air rescuer/technician/FA/staff |
| MIG-005 | Document and execute Redis backlog drain/discard plan before production cutover | Ops | Open | TBD | See queue migration doc |
| MIG-006 | Create client-facing exception list for data that cannot/will not be migrated | Governance | Open | TBD | Must be approved before go-live |
## Cutover Validation Checklist
1. Run DB migrations in target environment.
2. Run all required data migration scripts (including helicopter import flow if applicable).
3. Validate row counts and sample records for migrated domains.
4. Validate file accessibility for mandatory records (especially PDFs needed operationally).
5. Validate queue behavior post-cutover (`pending` to `published`, no legacy Redis dependency).
6. Record migration result, exceptions, and follow-up tickets in this page.
## Change Log
- 2026-04-17: Initial wiki created from current migration scripts and docs.

38390
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

17
docs/docs_test.go Normal file
View File

@@ -0,0 +1,17 @@
package docs
import (
"testing"
"github.com/swaggo/swag"
)
func TestInitRegistersSwaggerSpec(t *testing.T) {
registered := swag.GetSwagger(SwaggerInfo.InstanceName())
if registered == nil {
t.Fatalf("swagger spec %q was not registered", SwaggerInfo.InstanceName())
}
if registered.ReadDoc() == "" {
t.Fatalf("registered swagger doc is empty")
}
}

View File

@@ -0,0 +1,74 @@
# Duty Roster Migrations
Catatan eksekusi 6 file migrasi duty roster di [`migrations/`](../migrations/).
## Konfigurasi Tool Migrasi
Tool migrasi yang dipakai project ini adalah `goose` ([Makefile:5](../Makefile#L5)) dengan direktori target:
```makefile
GOOSE_DIR ?= migrations/goose
```
**Konsekuensi:** `make migrate-up` hanya menjalankan file di [`migrations/goose/`](../migrations/goose/). Semua file `.sql` di root [`migrations/`](../migrations/) — termasuk 6 file duty roster — **tidak otomatis dieksekusi**. Mereka berfungsi sebagai script SQL ad-hoc / log historis dan harus dijalankan manual (`mysql < file.sql`) atau dipindahkan ke `migrations/goose/` dengan format `-- +goose Up` / `-- +goose Down`.
## Urutan Eksekusi (by Timestamp)
Goose mengurutkan file by filename ascending. Untuk dua file dengan timestamp sama (`20260502`), urutannya alfabetis: `duty` < `purge`.
```
1. 20260408_hems_duty_roster_crews_confirm_at.sql
2. 20260429_rename_duty_roster_tables.sql
3. 20260430_normalize_duty_roster_legacy.sql
4. 20260502_duty_roster_permissions_unification.sql
5. 20260502_purge_legacy_hems_duty_roster_permissions.sql
6. 20260504_duty_roster_drop_unique_base_date.sql
```
## Efek per File
| # | File | Efek di DB |
|---|---|---|
| 1 | [`20260408_hems_duty_roster_crews_confirm_at.sql`](../migrations/20260408_hems_duty_roster_crews_confirm_at.sql) | `ALTER TABLE hems_duty_roster_crews ADD COLUMN confirm_at DATETIME NULL` (nama tabel lama). Idempoten via `IF NOT EXISTS`. |
| 2 | [`20260429_rename_duty_roster_tables.sql`](../migrations/20260429_rename_duty_roster_tables.sql) | `RENAME TABLE hems_duty_rosters → duty_rosters` dan `hems_duty_roster_crews → duty_roster_crews`. Pakai guard cek `information_schema.TABLES` jadi idempoten. |
| 3 | [`20260430_normalize_duty_roster_legacy.sql`](../migrations/20260430_normalize_duty_roster_legacy.sql) | (a) Rename tabel **lagi** dengan guard sama (no-op kalau #2 sukses). (b) Rename kolom `hems_base_id → base_id` di `duty_rosters`. (c) Ensure `confirm_at` ada di `duty_roster_crews` baru (dan di tabel lama jika masih ada). (d) Rewire FK `roster_crews.roster_id` & `duty_roster_other_persons.roster_id` supaya reference `duty_rosters` (bukan `hems_duty_rosters`). |
| 4 | [`20260502_duty_roster_permissions_unification.sql`](../migrations/20260502_duty_roster_permissions_unification.sql) | Insert 4 permission canonical: `duty_roster.{create,read,update,delete}` (skip via `WHERE NOT EXISTS`). Grant ke role yang punya legacy `hems_duty_roster.*` ke canonical baru, anti-duplikasi via `LEFT JOIN ... NOT EXISTS`. **Tidak menghapus legacy.** |
| 5 | [`20260502_purge_legacy_hems_duty_roster_permissions.sql`](../migrations/20260502_purge_legacy_hems_duty_roster_permissions.sql) | (a) Re-insert canonical (no-op jika #4 sudah jalan). (b) Re-grant (no-op). (c) `DELETE FROM role_permissions WHERE permission.key IN (legacy)`. (d) `DELETE FROM permissions WHERE key IN (legacy)`. |
| 6 | [`20260504_duty_roster_drop_unique_base_date.sql`](../migrations/20260504_duty_roster_drop_unique_base_date.sql) | `DROP INDEX idx_roster_base_date` (sebelumnya unique). `CREATE INDEX idx_roster_base_date ON duty_rosters(base_id, duty_date)` non-unique. **Konsekuensi aplikasi**: schema tidak lagi mencegah multiple roster di pasangan `(base_id, duty_date)` yang sama — kode handler harus mendukung skenario ini (lihat [`ensureRosterDates`](../internal/transport/http/handlers/duty_roster_handler.go) yang sebelumnya dedup-by-date). |
## Overlap & Redundansi
- **#2#3** — File `0430_normalize_legacy` sudah include semua rename dari `0429` dengan guard yang sama. File `0429` secara fungsional redundant; akan no-op jika dijalankan setelah `0430`.
- **#4#5** — File `0502_purge_legacy` adalah superset dari `0502_unification`. Insert + grant identik, lalu tambah `DELETE`. File `0502_unification` redundant.
## Idempotensi Re-run
| File | Idempoten? | Catatan |
|---|---|---|
| #1 | Ya | `ADD COLUMN IF NOT EXISTS` |
| #2 | Ya | Guard via `information_schema.TABLES` |
| #3 | Ya | Setiap `ALTER` dibungkus prepared statement dengan kondisi |
| #4 | Ya | `WHERE NOT EXISTS` untuk insert + `LEFT JOIN ... IS NULL` untuk grant |
| #5 | Ya | Sama dengan #4, ditambah `DELETE` (idempoten karena hanya hapus key legacy) |
| **#6** | **TIDAK** | `DROP INDEX idx_roster_base_date` tanpa guard. Re-run setelah index sudah di-drop + recreate (sebagai non-unique) akan **gagal** karena nama indexnya sama. Mestinya pakai prepared statement guard yang cek `information_schema.STATISTICS`. |
## Versi Minimal yang Setara
Dengan urutan dijalankan penuh, efek akhirnya cukup dicapai dengan 4 file:
```
0408_..._confirm_at (kompat untuk env yang masih punya hems_duty_*)
0430_normalize_legacy (rename tabel + kolom + FK + confirm_at — superset dari 0429)
0502_purge_legacy (canonical perm + delete legacy — superset dari 0502_unification)
0504_drop_unique_base_date (butuh tambah guard agar idempoten)
```
Untuk env yang sudah pasti **fresh** (tanpa sisa `hems_duty_*` apa pun), kelima file di atas bisa digabung jadi satu file goose-format dengan branch yang relevan saja. **Sebelum melakukan ini, pastikan tidak ada DB target yang masih nyangkut di mid-state** — file individual lebih aman selama transisi.
## Bug yang Pernah Ditemukan Akibat #6
Setelah `idx_roster_base_date` jadi non-unique, beberapa baris kode aplikasi yang asumsi unique (`base_id`, `duty_date`) jadi salah:
- [`ensureRosterDates`](../internal/transport/http/handlers/duty_roster_handler.go) di handler GET `/duty-roster/get-all` & `/get-all/dt` sebelumnya pakai `map[string]R` keyed by date — multiple roster di tanggal sama di-overwrite, hanya 1 yang muncul. Sudah diperbaiki pakai `map[string][]R` (multi-value).
Pastikan setiap kode baru yang query/iterasi roster per `(base_id, duty_date)` menyadari bahwa kombinasi ini tidak lagi unique.

735
docs/error-catalog.md Normal file
View File

@@ -0,0 +1,735 @@
# Error Catalog
Dokumen ini berisi daftar lengkap kontrak error yang dikembalikan backend (`apperrorsx`). Single source of truth ada pada [internal/shared/pkg/apperrorsx/codes_defs.go](../internal/shared/pkg/apperrorsx/codes_defs.go).
## Error Code Format
Format `error_code`:
```text
[HTTP_STATUS(3)][MODULE_CODE(2)][CASE_CODE(2)]
```
Contoh:
- `4010202` → status `401`, module `02` (PIN), case `02` (PIN blocked)
- `4040302` → status `404`, module `03` (HELICOPTER), case `02` (Helicopter not found)
## Module Code
| Code | Module | Konstanta |
|---:|---|---|
| `00` | General | `ModuleGeneral` |
| `01` | Auth | `ModuleAuth` |
| `02` | PIN | `ModulePIN` |
| `03` | Helicopter | `ModuleHelicopter` |
| `04` | User | `ModuleUser` |
| `05` | Master Data | `ModuleMasterData` |
| `06` | Duty Roster | `ModuleDutyRoster` |
| `07` | Flight | `ModuleFlight` |
| `08` | Contact | `ModuleContact` |
| `09` | HEMS / Mission Operations | `ModuleMissionOps` |
| `10` | Facility | `ModuleFacility` |
| `11` | No ICAO Code | `ModuleHospital` |
| `12` | Federal State | `ModuleFederalState` |
| `13` | Nationality | `ModuleNationality` |
| `14` | ICAO | `ModuleICAO` |
| `15` | Country | `ModuleLand` |
| `16` | Base | `ModuleBase` |
| `17` | Health Insurance Companies | `ModuleHealthInsuranceCompanies` |
| `18` | Vocation | `ModuleVocation` |
| `19` | Medicine | `ModuleMedicine` |
| `20` | Master Settings | `ModuleMasterSettings` |
| `21` | File Manager | `ModuleFileManager` |
| `22` | OPC Data | `ModuleOPCData` |
| `23` | Patient Data | `ModulePatientData` |
| `24` | Insurance Patient Data | `ModuleInsurancePatientData` |
| `25` | Role | `ModuleRole` |
| `26` | Flight Data | `ModuleFlightData` |
| `27` | Air Rescuer Checklist | `ModuleAirRescuerChecklist` |
| `28` | Branding | `ModuleBranding` |
| `29` | DUL | `ModuleDUL` |
| `37` | After Flight Inspection | `ModuleAfterFlightInspection` |
| `30` | Takeover | `ModuleTakeover` |
| `31` | Fleet Status | `ModuleFleetStatus` |
## Catalog
### General (Module `00`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `INVALID_REQUEST` | `4000001` | 400 | Bad Request | invalid request |
| `DATA_NOT_FOUND` | `4040002` | 404 | Not found | data not found |
| `VALIDATION_ERROR` | `4220003` | 422 | Validation error | validation error |
| `BUSINESS_RULE_FAILED` | `4220004` | 422 | Business rule failed | business rule validation failed |
| `INTERNAL_SERVER_ERROR` | `5000005` | 500 | Internal server error | internal server error |
### Auth (Module `01`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `SESSION_EXPIRED` | `4010101` | 401 | Unauthorized | session expired |
| `TOKEN_INVALID` | `4010102` | 401 | Unauthorized | token is invalid |
| `USER_UNAUTHORIZED` | `4010103` | 401 | Unauthorized | user is unauthorized |
| `FORBIDDEN_ACCESS` | `4030104` | 403 | Forbidden | forbidden access |
| `AUTH_INVALID_JSON` | `4000105` | 400 | Bad Request | invalid json |
| `AUTH_REGISTRATION_DISABLED` | `4030106` | 403 | Forbidden | registration is disabled |
| `AUTH_ROLE_NOT_FOUND` | `4000107` | 400 | Bad Request | public registration role not found |
| `AUTH_REGISTRATION_FAILED` | `4000108` | 400 | Bad Request | registration failed |
| `AUTH_INVITE_FAILED` | `4000109` | 400 | Bad Request | invite failed |
| `AUTH_SET_PASSWORD_FAILED` | `4000110` | 400 | Bad Request | set password failed |
| `AUTH_SET_PASSWORD_TOKEN_INVALID` | `4000111` | 400 | Bad Request | set password token invalid |
| `AUTH_USERNAME_ALREADY_REGISTERED` | `4000112` | 400 | Bad Request | username already registered |
| `AUTH_RESET_PASSWORD_INVALID_LINK` | `4000113` | 400 | Bad Request | reset password link invalid |
| `AUTH_RESET_PASSWORD_FAILED` | `5000114` | 500 | Internal server error | reset password failed |
### PIN (Module `02`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `PIN_INCORRECT` | `4010201` | 401 | Unauthorized | invalid security pin |
| `PIN_BLOCKED` | `4230202` | 423 | Locked | PIN has been blocked |
| `PIN_ATTEMPT_LIMIT_REACHED` | `4290203` | 429 | Too many requests | PIN attempt limit reached |
| `PIN_NOT_SET` | `4090204` | 409 | PIN not configured | security PIN is not set |
| `PIN_NOT_BLOCKED` | `4090207` | 409 | PIN not blocked | security PIN is not blocked |
| `PIN_VALIDATION_FAILED` | `4220205` | 422 | Validation error | PIN validation failed |
| `PIN_VERIFICATION_REQUIRED` | `2020206` | 202 | PIN verification required | valid PIN action token is required |
### Helicopter (Module `03`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `HELICOPTER_DUPLICATE` | `4090301` | 409 | Conflict | helicopter identifier already exists |
| `HELICOPTER_NOT_FOUND` | `4040302` | 404 | Not found | helicopter not found |
| `HELICOPTER_RELATION_DELETE_ERROR` | `4090303` | 409 | Conflict | cannot delete helicopter due to active relations |
| `HELICOPTER_INVALID_PAYLOAD` | `4220304` | 422 | Validation error | invalid helicopter payload |
| `HELICOPTER_INVALID_JSON` | `4000305` | 400 | Bad Request | invalid json payload |
| `HELICOPTER_INVALID_ID` | `4220306` | 422 | Validation error | helicopter id is invalid |
| `HELICOPTER_ID_REQUIRED` | `4220307` | 422 | Validation error | helicopter id is required |
| `HELICOPTER_ID_MISMATCH` | `4220308` | 422 | Validation error | helicopter id mismatch |
| `HELICOPTER_ATTRIBUTES_REQUIRED` | `4220309` | 422 | Validation error | at least one attribute must be provided |
| `HELICOPTER_CREATE_FAILED` | `4000310` | 400 | Bad Request | failed to create helicopter |
| `HELICOPTER_UPDATE_FAILED` | `4000311` | 400 | Bad Request | failed to update helicopter |
| `HELICOPTER_DELETE_FAILED` | `4000312` | 400 | Bad Request | failed to delete helicopter |
| `HELICOPTER_GENERATE_FAILED` | `4000313` | 400 | Bad Request | failed to generate helicopter report number |
| `HELICOPTER_LIST_FAILED` | `4000314` | 400 | Bad Request | failed to list helicopter data |
| `HELICOPTER_PIN_VERIFICATION_REQUIRED` | `2020315` | 202 | PIN verification required | valid PIN action token is required |
| `HELICOPTER_UNAUTHORIZED` | `4010316` | 401 | Unauthorized | helicopter action unauthorized |
| `HELICOPTER_INVALID_TYPE` | `4220317` | 422 | Validation error | helicopter type is invalid |
| `HELICOPTER_FILE_NOT_FOUND` | `4040318` | 404 | Not found | helicopter file not found |
| `HELICOPTER_FILE_INVALID_PAYLOAD` | `4220319` | 422 | Validation error | invalid helicopter file payload |
| `HELICOPTER_FILE_INVALID_JSON` | `4000320` | 400 | Bad Request | invalid json payload |
| `HELICOPTER_FILE_INVALID_UUID` | `4220321` | 422 | Validation error | helicopter file uuid is invalid |
| `HELICOPTER_FILE_SECTION_INVALID` | `4220322` | 422 | Validation error | section is invalid |
| `HELICOPTER_FILE_FILE_NAME_REQUIRED` | `4220323` | 422 | Validation error | file_name is required |
| `HELICOPTER_FILE_CREATE_FAILED` | `4000324` | 400 | Bad Request | failed to create helicopter file |
| `HELICOPTER_FILE_UPDATE_FAILED` | `4000325` | 400 | Bad Request | failed to update helicopter file |
| `HELICOPTER_FILE_DELETE_FAILED` | `4000326` | 400 | Bad Request | failed to delete helicopter file |
| `HELICOPTER_FILE_GET_FAILED` | `4000327` | 400 | Bad Request | failed to get helicopter file |
| `HELICOPTER_FILE_LIST_FAILED` | `4000328` | 400 | Bad Request | failed to list helicopter file data |
### User (Module `04`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `USER_NOT_FOUND` | `4040401` | 404 | Not found | user not found |
| `USER_INVALID_PAYLOAD` | `4220402` | 422 | Validation error | invalid user payload |
| `USER_INVALID_JSON` | `4000403` | 400 | Bad Request | invalid json payload |
| `USER_INVALID_UUID` | `4220404` | 422 | Validation error | user uuid is invalid |
| `USER_VALIDATION_FAILED` | `4220405` | 422 | Validation error | user validation failed |
| `USER_CREATE_FAILED` | `4000406` | 400 | Bad Request | failed to create user |
| `USER_UPDATE_FAILED` | `4000407` | 400 | Bad Request | failed to update user |
| `USER_DELETE_FAILED` | `4000408` | 400 | Bad Request | failed to delete user |
| `USER_LIST_FAILED` | `4000409` | 400 | Bad Request | failed to list users |
| `USER_TIMEZONE_INVALID` | `4220410` | 422 | Validation error | timezone is invalid |
| `USER_ROLE_IDS_INVALID` | `4220411` | 422 | Validation error | role_ids contains invalid uuid |
| `USER_ROLE_IDS_REQUIRED` | `4220412` | 422 | Validation error | role_ids must contain at least one role |
| `USER_PRIMARY_ROLE_INVALID` | `4220413` | 422 | Validation error | primary role must be included in role_ids |
### Master Data (Module `05`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `MASTER_DATA_NOT_FOUND` | `4040501` | 404 | Not found | master data not found |
| `MASTER_DATA_INVALID_PAYLOAD` | `4220502` | 422 | Validation error | invalid master data payload |
| `MASTER_DATA_INVALID_JSON` | `4000503` | 400 | Bad Request | invalid json payload |
| `MASTER_DATA_VALIDATION_FAILED` | `4220504` | 422 | Validation error | master data validation failed |
| `MASTER_DATA_CREATE_FAILED` | `4000505` | 400 | Bad Request | failed to create master data |
| `MASTER_DATA_UPDATE_FAILED` | `4000506` | 400 | Bad Request | failed to update master data |
| `MASTER_DATA_DELETE_FAILED` | `4000507` | 400 | Bad Request | failed to delete master data |
| `MASTER_DATA_LIST_FAILED` | `4000508` | 400 | Bad Request | failed to list master data |
### Duty Roster (Module `06`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `DUTY_ROSTER_NOT_FOUND` | `4040601` | 404 | Not found | duty roster not found |
| `DUTY_ROSTER_INVALID_PAYLOAD` | `4220602` | 422 | Validation error | invalid duty roster payload |
| `DUTY_ROSTER_INVALID_JSON` | `4000603` | 400 | Bad Request | invalid json payload |
| `DUTY_ROSTER_INVALID_UUID` | `4220604` | 422 | Validation error | duty roster uuid is invalid |
| `DUTY_ROSTER_VALIDATION_FAILED` | `4220605` | 422 | Validation error | duty roster validation failed |
| `DUTY_ROSTER_CONFLICT` | `4090606` | 409 | Conflict | duty roster conflict |
| `DUTY_ROSTER_CREATE_FAILED` | `4000607` | 400 | Bad Request | failed to create duty roster |
| `DUTY_ROSTER_UPDATE_FAILED` | `4000608` | 400 | Bad Request | failed to update duty roster |
| `DUTY_ROSTER_DELETE_FAILED` | `4000609` | 400 | Bad Request | failed to delete duty roster |
| `DUTY_ROSTER_LIST_FAILED` | `4000610` | 400 | Bad Request | failed to list duty roster data |
| `DUTY_ROSTER_UNAUTHORIZED` | `4010611` | 401 | Unauthorized | duty roster unauthorized |
| `DUTY_ROSTER_BASE_TYPE_INVALID` | `4220612` | 422 | Validation error | duty roster base type is invalid |
| `DUTY_ROSTER_RELATION_DELETE_ERROR` | `4090613` | 409 | Conflict | cannot delete duty roster due to active relations |
### DUL (Module `29`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `DUL_NOT_FOUND` | `4042901` | 404 | Not found | dul not found |
| `DUL_INVALID_PAYLOAD` | `4222902` | 422 | Validation error | invalid dul payload |
| `DUL_INVALID_JSON` | `4002903` | 400 | Bad Request | invalid json payload |
| `DUL_INVALID_UUID` | `4222904` | 422 | Validation error | dul uuid is invalid |
| `DUL_VALIDATION_FAILED` | `4222905` | 422 | Validation error | dul validation failed |
| `DUL_CREATE_FAILED` | `4002906` | 400 | Bad Request | failed to create dul |
| `DUL_UPDATE_FAILED` | `4002907` | 400 | Bad Request | failed to update dul |
| `DUL_DELETE_FAILED` | `4002908` | 400 | Bad Request | failed to delete dul |
| `DUL_LIST_FAILED` | `4002909` | 400 | Bad Request | failed to list dul |
| `DUL_DATE_INVALID` | `4222910` | 422 | Validation error | date must be YYYY-MM-DD |
### Takeover (Module `30`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `TAKEOVER_NOT_FOUND` | `4043001` | 404 | Not found | takeover not found |
| `TAKEOVER_INVALID_PAYLOAD` | `4223002` | 422 | Validation error | invalid takeover payload |
| `TAKEOVER_INVALID_JSON` | `4003003` | 400 | Bad Request | invalid json payload |
| `TAKEOVER_INVALID_UUID` | `4223004` | 422 | Validation error | takeover uuid is invalid |
| `TAKEOVER_ID_REQUIRED` | `4223005` | 422 | Validation error | id is required |
| `TAKEOVER_ID_MISMATCH` | `4223006` | 422 | Validation error | id does not match path uuid |
| `TAKEOVER_FILE_SECTION_INVALID` | `4223007` | 422 | Validation error | helicopter_file_id does not belong to selected helicopter/section |
| `TAKEOVER_CREATE_FAILED` | `4003007` | 400 | Bad Request | failed to create takeover |
| `TAKEOVER_UPDATE_FAILED` | `4003008` | 400 | Bad Request | failed to update takeover |
| `TAKEOVER_DELETE_FAILED` | `4003009` | 400 | Bad Request | failed to delete takeover |
| `TAKEOVER_GET_FAILED` | `4003010` | 400 | Bad Request | failed to get takeover |
| `TAKEOVER_LIST_FAILED` | `4003011` | 400 | Bad Request | failed to list takeover data |
| `TAKEOVER_HELICOPTER_IN_USE` | `4093013` | 409 | Conflict | helicopter is already booked and cannot be used |
### Fleet Status (Module `31`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FLEET_STATUS_NOT_FOUND` | `4043101` | 404 | Not found | fleet status not found |
| `FLEET_STATUS_INVALID_PAYLOAD` | `4223102` | 422 | Validation error | invalid fleet status payload |
| `FLEET_STATUS_INVALID_JSON` | `4003103` | 400 | Bad Request | invalid json payload |
| `FLEET_STATUS_INVALID_ID` | `4223104` | 422 | Validation error | fleet status id is invalid |
| `FLEET_STATUS_ID_REQUIRED` | `4223105` | 422 | Validation error | id is required |
| `FLEET_STATUS_ID_MISMATCH` | `4223106` | 422 | Validation error | id does not match path id |
| `FLEET_STATUS_HELICOPTER_REQUIRED` | `4223107` | 422 | Validation error | helicopter_id is required |
| `FLEET_STATUS_HELICOPTER_INVALID` | `4223108` | 422 | Validation error | referenced helicopter does not exist |
| `FLEET_STATUS_DUPLICATE` | `4093109` | 409 | Conflict | fleet status already exists |
| `FLEET_STATUS_RELATION_DELETE_ERROR` | `4093110` | 409 | Conflict | cannot delete fleet status due to active relations |
| `FLEET_STATUS_CREATE_FAILED` | `4003111` | 400 | Bad Request | failed to create fleet status |
| `FLEET_STATUS_UPDATE_FAILED` | `4003112` | 400 | Bad Request | failed to update fleet status |
| `FLEET_STATUS_DELETE_FAILED` | `4003113` | 400 | Bad Request | failed to delete fleet status |
| `FLEET_STATUS_LIST_FAILED` | `4003114` | 400 | Bad Request | failed to list fleet status data |
| `FLEET_STATUS_MARK_SERVICED_FAILED` | `4003115` | 400 | Bad Request | failed to mark fleet status serviced |
### Flight (Module `07`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FLIGHT_NOT_FOUND` | `4040701` | 404 | Not found | flight not found |
| `FLIGHT_INVALID_PAYLOAD` | `4220702` | 422 | Validation error | invalid flight payload |
| `FLIGHT_INVALID_JSON` | `4000703` | 400 | Bad Request | invalid json payload |
| `FLIGHT_INVALID_UUID` | `4220704` | 422 | Validation error | flight uuid is invalid |
| `FLIGHT_VALIDATION_FAILED` | `4220705` | 422 | Validation error | flight validation failed |
| `FLIGHT_CREATE_FAILED` | `4000706` | 400 | Bad Request | failed to create flight |
| `FLIGHT_GET_FAILED` | `4000707` | 400 | Bad Request | failed to get flight |
| `FLIGHT_LIST_FAILED` | `4000708` | 400 | Bad Request | failed to list flight data |
| `FLIGHT_UNAUTHORIZED` | `4010709` | 401 | Unauthorized | flight action unauthorized |
| `FLIGHT_DATE_INVALID` | `4220710` | 422 | Validation error | date must be YYYY-MM-DD |
### Contact (Module `08`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `CONTACT_NOT_FOUND` | `4040801` | 404 | Not found | contact not found |
| `CONTACT_INVALID_PAYLOAD` | `4220802` | 422 | Validation error | invalid contact payload |
| `CONTACT_INVALID_JSON` | `4000803` | 400 | Bad Request | invalid json payload |
| `CONTACT_INVALID_USER_ID` | `4220804` | 422 | Validation error | user_id is invalid uuid |
| `CONTACT_VALIDATION_FAILED` | `4220805` | 422 | Validation error | contact validation failed |
| `CONTACT_CREATE_FAILED` | `4000806` | 400 | Bad Request | failed to create contact |
| `CONTACT_GET_FAILED` | `4000807` | 400 | Bad Request | failed to get contact |
| `CONTACT_UPDATE_FAILED` | `4000808` | 400 | Bad Request | failed to update contact |
| `CONTACT_USERNAME_CONFLICT` | `4090809` | 409 | Conflict | username already exists |
| `CONTACT_DELETE_FAILED` | `4000810` | 400 | Bad Request | failed to delete contact |
| `CONTACT_UPDATE_STATUS_FAILED` | `4000811` | 400 | Bad Request | failed to update contact status |
| `CONTACT_UPDATE_ROLE_FAILED` | `4000812` | 400 | Bad Request | failed to update contact role |
| `CONTACT_UPDATE_PILOT_CATEGORY_FAILED` | `4000813` | 400 | Bad Request | failed to update contact pilot category |
| `CONTACT_UPDATE_LICENSE_FAILED` | `4000814` | 400 | Bad Request | failed to update contact license |
| `CONTACT_UPDATE_CHIEF_FLAGS_FAILED` | `4000815` | 400 | Bad Request | failed to update contact chief flags |
| `CONTACT_UPDATE_RESPONSIBLE_COMPLAINTS_FAILED` | `4000816` | 400 | Bad Request | failed to update contact responsible complaints |
| `CONTACT_BULK_UPDATE_FAILED` | `4000817` | 400 | Bad Request | failed to bulk update contacts |
| `CONTACT_LIST_FAILED` | `4000818` | 400 | Bad Request | failed to list contacts |
### HEMS Operation / Mission Ops (Module `09`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `HEMS_OPERATION_NOT_FOUND` | `4040901` | 404 | Not found | hems operation not found |
| `HEMS_OPERATION_INVALID_PAYLOAD` | `4220902` | 422 | Validation error | invalid hems operation payload |
| `HEMS_OPERATION_INVALID_JSON` | `4000903` | 400 | Bad Request | invalid json payload |
| `HEMS_OPERATION_INVALID_UUID` | `4220904` | 422 | Validation error | hems operation uuid is invalid |
| `HEMS_OPERATION_ATTRIBUTES_REQUIRED` | `4220905` | 422 | Validation error | at least one hems operation attribute must be provided |
| `HEMS_OPERATION_DATE_INVALID_RFC3339` | `4220906` | 422 | Validation error | date must be in RFC3339 format |
| `HEMS_OPERATION_RELATION_REFERENCE_INVALID` | `4220907` | 422 | Validation error | relation reference is invalid |
| `HEMS_OPERATION_CREATE_FAILED` | `4000908` | 400 | Bad Request | failed to create hems operation |
| `HEMS_OPERATION_UPDATE_FAILED` | `4000909` | 400 | Bad Request | failed to update hems operation |
| `HEMS_OPERATION_DELETE_FAILED` | `4000910` | 400 | Bad Request | failed to delete hems operation |
| `HEMS_OPERATION_LIST_FAILED` | `4000911` | 400 | Bad Request | failed to list hems operation |
### Forces Present (Module `09`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FORCES_PRESENT_NOT_FOUND` | `4040912` | 404 | Not found | forces present not found |
| `FORCES_PRESENT_RELATION_DELETE_ERROR` | `4090913` | 409 | Conflict | cannot delete forces present due to active relations |
| `FORCES_PRESENT_INVALID_PAYLOAD` | `4220914` | 422 | Validation error | invalid forces present payload |
| `FORCES_PRESENT_INVALID_JSON` | `4000915` | 400 | Bad Request | invalid json payload |
| `FORCES_PRESENT_INVALID_UUID` | `4220916` | 422 | Validation error | forces present uuid is invalid |
| `FORCES_PRESENT_ID_REQUIRED` | `4220917` | 422 | Validation error | forces present id is required |
| `FORCES_PRESENT_ID_MISMATCH` | `4220918` | 422 | Validation error | forces present id mismatch |
| `FORCES_PRESENT_ATTRIBUTES_REQUIRED` | `4220919` | 422 | Validation error | at least one forces present attribute must be provided |
| `FORCES_PRESENT_NAME_REQUIRED` | `4220920` | 422 | Validation error | forces present name is required |
| `FORCES_PRESENT_CREATE_FAILED` | `4000921` | 400 | Bad Request | failed to create forces present |
| `FORCES_PRESENT_UPDATE_FAILED` | `4000922` | 400 | Bad Request | failed to update forces present |
| `FORCES_PRESENT_DELETE_FAILED` | `4000923` | 400 | Bad Request | failed to delete forces present |
| `FORCES_PRESENT_LIST_FAILED` | `4000924` | 400 | Bad Request | failed to list forces present data |
### Mission (Module `09`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `MISSION_NOT_FOUND` | `4040925` | 404 | Not found | mission not found |
| `MISSION_INVALID_PAYLOAD` | `4220926` | 422 | Validation error | invalid mission payload |
| `MISSION_INVALID_JSON` | `4000927` | 400 | Bad Request | invalid json payload |
| `MISSION_INVALID_FLIGHT_UUID` | `4220928` | 422 | Validation error | flight uuid is invalid |
| `MISSION_FLIGHT_ID_REQUIRED` | `4220929` | 422 | Validation error | flight_id is required |
| `MISSION_MISSION_ID_REQUIRED` | `4220930` | 422 | Validation error | mission_id is required |
| `MISSION_TYPE_INVALID` | `4220931` | 422 | Validation error | mission type is invalid |
| `MISSION_SUBTYPE_REQUIRED` | `4220932` | 422 | Validation error | subtype_id is required |
| `MISSION_SUBTYPE_INVALID` | `4220933` | 422 | Validation error | subtype_id is invalid |
| `MISSION_SUBTYPE_FORBIDDEN` | `4220934` | 422 | Validation error | subtype_id is forbidden |
| `MISSION_DUPLICATE_FLIGHT` | `4090935` | 409 | Conflict | mission for this flight already exists |
| `MISSION_CREATE_FAILED` | `4000936` | 400 | Bad Request | failed to create mission |
| `MISSION_UPDATE_FAILED` | `4000937` | 400 | Bad Request | failed to update mission |
| `MISSION_DELETE_FAILED` | `4000938` | 400 | Bad Request | failed to delete mission |
| `MISSION_GET_FAILED` | `4000939` | 400 | Bad Request | failed to get mission |
| `MISSION_INVALID_MISSION_UUID` | `4220940` | 422 | Validation error | mission uuid is invalid |
| `MISSION_LIST_FAILED` | `4000941` | 400 | Bad Request | failed to list mission data |
| `MISSION_AFTER_FLIGHT_LOCKED` | `4230942` | 423 | Locked | mission is locked because after flight inspection already exists |
### Facility (Module `10`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FACILITY_NOT_FOUND` | `4041001` | 404 | Not found | facility not found |
| `FACILITY_RELATION_DELETE_ERROR` | `4091002` | 409 | Conflict | cannot delete facility due to active relations |
| `FACILITY_INVALID_PAYLOAD` | `4221003` | 422 | Validation error | invalid facility payload |
| `FACILITY_INVALID_JSON` | `4001004` | 400 | Bad Request | invalid json payload |
| `FACILITY_INVALID_UUID` | `4221005` | 422 | Validation error | facility uuid is invalid |
| `FACILITY_ATTRIBUTES_REQUIRED` | `4221006` | 422 | Validation error | at least one facility attribute must be provided |
| `FACILITY_CATEGORY_REQUIRED` | `4221007` | 422 | Validation error | facility category is required |
| `FACILITY_NAME_REQUIRED` | `4221008` | 422 | Validation error | facility name is required |
| `FACILITY_TYPE_REQUIRED` | `4221009` | 422 | Validation error | facility type is required |
| `FACILITY_CREATE_FAILED` | `4001010` | 400 | Bad Request | failed to create facility |
| `FACILITY_UPDATE_FAILED` | `4001011` | 400 | Bad Request | failed to update facility |
| `FACILITY_DELETE_FAILED` | `4001012` | 400 | Bad Request | failed to delete facility |
| `FACILITY_LIST_FAILED` | `4001013` | 400 | Bad Request | failed to list facility data |
### No ICAO Code (Module `11`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `NO_ICAO_CODE_NOT_FOUND` | `4041101` | 404 | Not found | no icao code not found |
| `NO_ICAO_CODE_RELATION_DELETE_ERROR` | `4091102` | 409 | Conflict | cannot delete no icao code due to active relations |
| `NO_ICAO_CODE_INVALID_PAYLOAD` | `4221103` | 422 | Validation error | invalid no icao code payload |
| `NO_ICAO_CODE_INVALID_JSON` | `4001104` | 400 | Bad Request | invalid json payload |
| `NO_ICAO_CODE_INVALID_UUID` | `4221105` | 422 | Validation error | no icao code uuid is invalid |
| `NO_ICAO_CODE_ID_REQUIRED` | `4221106` | 422 | Validation error | no icao code id is required |
| `NO_ICAO_CODE_ID_MISMATCH` | `4221107` | 422 | Validation error | no icao code id mismatch |
| `NO_ICAO_CODE_ATTRIBUTES_REQUIRED` | `4221108` | 422 | Validation error | at least one no icao code attribute must be provided |
| `NO_ICAO_CODE_NAME_REQUIRED` | `4221109` | 422 | Validation error | no icao code name is required |
| `NO_ICAO_CODE_CREATE_FAILED` | `4001110` | 400 | Bad Request | failed to create no icao code |
| `NO_ICAO_CODE_UPDATE_FAILED` | `4001111` | 400 | Bad Request | failed to update no icao code |
| `NO_ICAO_CODE_DELETE_FAILED` | `4001112` | 400 | Bad Request | failed to delete no icao code |
| `NO_ICAO_CODE_LIST_FAILED` | `4001113` | 400 | Bad Request | failed to list no icao code data |
### Federal State (Module `12`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FEDERAL_STATE_NOT_FOUND` | `4041201` | 404 | Not found | federal state not found |
| `FEDERAL_STATE_RELATION_DELETE_ERROR` | `4091202` | 409 | Conflict | cannot delete federal state due to active relations |
| `FEDERAL_STATE_INVALID_PAYLOAD` | `4221203` | 422 | Validation error | invalid federal state payload |
| `FEDERAL_STATE_INVALID_JSON` | `4001204` | 400 | Bad Request | invalid json payload |
| `FEDERAL_STATE_INVALID_ID` | `4221205` | 422 | Validation error | federal state id is invalid |
| `FEDERAL_STATE_LAND_ID_INVALID` | `4221206` | 422 | Validation error | federal state land_id is invalid |
| `FEDERAL_STATE_ID_REQUIRED` | `4221207` | 422 | Validation error | federal state id is required |
| `FEDERAL_STATE_ID_MISMATCH` | `4221208` | 422 | Validation error | federal state id mismatch |
| `FEDERAL_STATE_ATTRIBUTES_REQUIRED` | `4221209` | 422 | Validation error | at least one federal state attribute must be provided |
| `FEDERAL_STATE_NAME_REQUIRED` | `4221210` | 422 | Validation error | federal state name is required |
| `FEDERAL_STATE_CREATE_FAILED` | `4001211` | 400 | Bad Request | failed to create federal state |
| `FEDERAL_STATE_UPDATE_FAILED` | `4001212` | 400 | Bad Request | failed to update federal state |
| `FEDERAL_STATE_DELETE_FAILED` | `4001213` | 400 | Bad Request | failed to delete federal state |
| `FEDERAL_STATE_LIST_FAILED` | `4001214` | 400 | Bad Request | failed to list federal state data |
### Nationality (Module `13`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `NATIONALITY_NOT_FOUND` | `4041301` | 404 | Not found | nationality not found |
| `NATIONALITY_RELATION_DELETE_ERROR` | `4091302` | 409 | Conflict | cannot delete nationality due to active relations |
| `NATIONALITY_INVALID_PAYLOAD` | `4221303` | 422 | Validation error | invalid nationality payload |
| `NATIONALITY_INVALID_JSON` | `4001304` | 400 | Bad Request | invalid json payload |
| `NATIONALITY_INVALID_UUID` | `4221305` | 422 | Validation error | nationality uuid is invalid |
| `NATIONALITY_ID_REQUIRED` | `4221306` | 422 | Validation error | nationality id is required |
| `NATIONALITY_ID_MISMATCH` | `4221307` | 422 | Validation error | nationality id mismatch |
| `NATIONALITY_ATTRIBUTES_REQUIRED` | `4221308` | 422 | Validation error | at least one nationality attribute must be provided |
| `NATIONALITY_NAME_REQUIRED` | `4221309` | 422 | Validation error | nationality name is required |
| `NATIONALITY_CREATE_FAILED` | `4001310` | 400 | Bad Request | failed to create nationality |
| `NATIONALITY_UPDATE_FAILED` | `4001311` | 400 | Bad Request | failed to update nationality |
| `NATIONALITY_DELETE_FAILED` | `4001312` | 400 | Bad Request | failed to delete nationality |
| `NATIONALITY_LIST_FAILED` | `4001313` | 400 | Bad Request | failed to list nationality data |
### ICAO (Module `14`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `ICAO_NOT_FOUND` | `4041401` | 404 | Not found | icao not found |
| `ICAO_RELATION_DELETE_ERROR` | `4091402` | 409 | Conflict | cannot delete icao due to active relations |
| `ICAO_INVALID_PAYLOAD` | `4221403` | 422 | Validation error | invalid icao payload |
| `ICAO_INVALID_JSON` | `4001404` | 400 | Bad Request | invalid json payload |
| `ICAO_INVALID_UUID` | `4221405` | 422 | Validation error | icao uuid is invalid |
| `ICAO_ID_REQUIRED` | `4221406` | 422 | Validation error | icao id is required |
| `ICAO_ID_MISMATCH` | `4221407` | 422 | Validation error | icao id mismatch |
| `ICAO_ATTRIBUTES_REQUIRED` | `4221408` | 422 | Validation error | at least one icao attribute must be provided |
| `ICAO_CODE_REQUIRED` | `4221409` | 422 | Validation error | icao code is required |
| `ICAO_LAND_ID_INVALID` | `4221410` | 422 | Validation error | icao land_id is invalid |
| `ICAO_FEDERAL_STATE_ID_INVALID` | `4221411` | 422 | Validation error | icao federal_state_id is invalid |
| `ICAO_CREATE_FAILED` | `4001412` | 400 | Bad Request | failed to create icao |
| `ICAO_UPDATE_FAILED` | `4001413` | 400 | Bad Request | failed to update icao |
| `ICAO_DELETE_FAILED` | `4001414` | 400 | Bad Request | failed to delete icao |
| `ICAO_LIST_FAILED` | `4001415` | 400 | Bad Request | failed to list icao data |
### Country (Module `15`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `COUNTRY_NOT_FOUND` | `4041501` | 404 | Not found | country not found |
| `COUNTRY_RELATION_DELETE_ERROR` | `4091502` | 409 | Conflict | cannot delete country due to active relations |
| `COUNTRY_INVALID_PAYLOAD` | `4221503` | 422 | Validation error | invalid country payload |
| `COUNTRY_INVALID_JSON` | `4001504` | 400 | Bad Request | invalid json payload |
| `COUNTRY_INVALID_ID` | `4221505` | 422 | Validation error | country id is invalid |
| `COUNTRY_ID_REQUIRED` | `4221506` | 422 | Validation error | country id is required |
| `COUNTRY_ID_MISMATCH` | `4221507` | 422 | Validation error | country id mismatch |
| `COUNTRY_ATTRIBUTES_REQUIRED` | `4221508` | 422 | Validation error | at least one country attribute must be provided |
| `COUNTRY_NAME_REQUIRED` | `4221509` | 422 | Validation error | country name is required |
| `COUNTRY_ISO_CODE_INVALID` | `4221510` | 422 | Validation error | country iso_code is invalid |
| `COUNTRY_ISO_CODE_DUPLICATE` | `4091511` | 409 | Conflict | country iso_code already exists |
| `COUNTRY_CREATE_FAILED` | `4001512` | 400 | Bad Request | failed to create country |
| `COUNTRY_UPDATE_FAILED` | `4001513` | 400 | Bad Request | failed to update country |
| `COUNTRY_DELETE_FAILED` | `4001514` | 400 | Bad Request | failed to delete country |
| `COUNTRY_LIST_FAILED` | `4001515` | 400 | Bad Request | failed to list country data |
### Base (Module `16`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `BASE_NOT_FOUND` | `4041601` | 404 | Not found | base not found |
| `BASE_DELETE_CONFLICT` | `4091615` | 409 | Conflict | cannot delete base because it is referenced by another record |
| `BASE_INVALID_PAYLOAD` | `4221603` | 422 | Validation error | invalid base payload |
| `BASE_INVALID_JSON` | `4001604` | 400 | Bad Request | invalid json payload |
| `BASE_INVALID_UUID` | `4221605` | 422 | Validation error | base uuid is invalid |
| `BASE_INVALID_CATEGORY` | `4221606` | 422 | Validation error | base category is invalid |
| `BASE_ATTRIBUTES_REQUIRED` | `4221607` | 422 | Validation error | at least one base attribute must be provided |
| `BASE_NAME_REQUIRED` | `4221608` | 422 | Validation error | base name is required |
| `BASE_DEFAULT_SHIFT_START_REQUIRED` | `4221616` | 422 | Validation error | default_shift_start is required |
| `BASE_DEFAULT_SHIFT_END_REQUIRED` | `4221617` | 422 | Validation error | default_shift_end is required |
| `BASE_CREATE_FAILED` | `4001609` | 400 | Bad Request | failed to create base |
| `BASE_UPDATE_FAILED` | `4001610` | 400 | Bad Request | failed to update base |
| `BASE_DELETE_FAILED` | `4001611` | 400 | Bad Request | failed to delete base |
| `BASE_LIST_FAILED` | `4001612` | 400 | Bad Request | failed to list base data |
| `BASE_INVALID_CONTACT_IDS` | `4221613` | 422 | Validation error | base contact ids are invalid |
| `BASE_INVALID_TIME` | `4221614` | 422 | Validation error | base time format is invalid |
Catatan:
- `BASE_DELETE_CONFLICT` mengembalikan detail relasi yang masih mengacu ke base, misalnya `takeover`, `dul`, `base operational shift times`, atau `base contact roles`.
- Detail pesan bisa berubah sesuai tabel relasi yang ditemukan saat delete.
### Health Insurance Companies (Module `17`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `HEALTH_INSURANCE_COMPANY_NOT_FOUND` | `4041701` | 404 | Not found | health insurance company not found |
| `HEALTH_INSURANCE_COMPANY_RELATION_DELETE_ERROR` | `4091702` | 409 | Conflict | cannot delete health insurance company due to active relations |
| `HEALTH_INSURANCE_COMPANY_INVALID_PAYLOAD` | `4221703` | 422 | Validation error | invalid health insurance company payload |
| `HEALTH_INSURANCE_COMPANY_INVALID_JSON` | `4001704` | 400 | Bad Request | invalid json payload |
| `HEALTH_INSURANCE_COMPANY_INVALID_UUID` | `4221705` | 422 | Validation error | health insurance company uuid is invalid |
| `HEALTH_INSURANCE_COMPANY_ID_REQUIRED` | `4221706` | 422 | Validation error | health insurance company id is required |
| `HEALTH_INSURANCE_COMPANY_ID_MISMATCH` | `4221707` | 422 | Validation error | health insurance company id mismatch |
| `HEALTH_INSURANCE_COMPANY_ATTRIBUTES_REQUIRED` | `4221708` | 422 | Validation error | at least one health insurance company attribute must be provided |
| `HEALTH_INSURANCE_COMPANY_NAME_REQUIRED` | `4221709` | 422 | Validation error | health insurance company name is required |
| `HEALTH_INSURANCE_COMPANY_LAND_ID_INVALID` | `4221710` | 422 | Validation error | health insurance company land_id is invalid |
| `HEALTH_INSURANCE_COMPANY_CREATE_FAILED` | `4001711` | 400 | Bad Request | failed to create health insurance company |
| `HEALTH_INSURANCE_COMPANY_UPDATE_FAILED` | `4001712` | 400 | Bad Request | failed to update health insurance company |
| `HEALTH_INSURANCE_COMPANY_DELETE_FAILED` | `4001713` | 400 | Bad Request | failed to delete health insurance company |
| `HEALTH_INSURANCE_COMPANY_LIST_FAILED` | `4001714` | 400 | Bad Request | failed to list health insurance company data |
### Vocation (Module `18`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `VOCATION_NOT_FOUND` | `4041801` | 404 | Not found | vocation not found |
| `VOCATION_RELATION_DELETE_ERROR` | `4091802` | 409 | Conflict | cannot delete vocation due to active relations |
| `VOCATION_INVALID_PAYLOAD` | `4221803` | 422 | Validation error | invalid vocation payload |
| `VOCATION_INVALID_JSON` | `4001804` | 400 | Bad Request | invalid json payload |
| `VOCATION_INVALID_UUID` | `4221805` | 422 | Validation error | vocation uuid is invalid |
| `VOCATION_ID_REQUIRED` | `4221806` | 422 | Validation error | vocation id is required |
| `VOCATION_ID_MISMATCH` | `4221807` | 422 | Validation error | vocation id mismatch |
| `VOCATION_ATTRIBUTES_REQUIRED` | `4221808` | 422 | Validation error | at least one vocation attribute must be provided |
| `VOCATION_NAME_REQUIRED` | `4221809` | 422 | Validation error | vocation name is required |
| `VOCATION_CREATE_FAILED` | `4001810` | 400 | Bad Request | failed to create vocation |
| `VOCATION_UPDATE_FAILED` | `4001811` | 400 | Bad Request | failed to update vocation |
| `VOCATION_DELETE_FAILED` | `4001812` | 400 | Bad Request | failed to delete vocation |
| `VOCATION_LIST_FAILED` | `4001813` | 400 | Bad Request | failed to list vocation data |
### Medicine (Module `19`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `MEDICINE_NOT_FOUND` | `4041901` | 404 | Not found | medicine not found |
| `MEDICINE_RELATION_DELETE_ERROR` | `4091902` | 409 | Conflict | cannot delete medicine due to active relations |
| `MEDICINE_INVALID_PAYLOAD` | `4221903` | 422 | Validation error | invalid medicine payload |
| `MEDICINE_INVALID_JSON` | `4001904` | 400 | Bad Request | invalid json payload |
| `MEDICINE_INVALID_UUID` | `4221905` | 422 | Validation error | medicine uuid is invalid |
| `MEDICINE_ID_REQUIRED` | `4221906` | 422 | Validation error | medicine id is required |
| `MEDICINE_ID_MISMATCH` | `4221907` | 422 | Validation error | medicine id mismatch |
| `MEDICINE_ATTRIBUTES_REQUIRED` | `4221908` | 422 | Validation error | at least one medicine attribute must be provided |
| `MEDICINE_NAME_REQUIRED` | `4221909` | 422 | Validation error | medicine name is required |
| `MEDICINE_GROUP_ID_INVALID` | `4221910` | 422 | Validation error | medicine_group_id is invalid |
| `MEDICINE_MOTOR_REACTION_ID_INVALID` | `4221911` | 422 | Validation error | motor_reaction_id is invalid |
| `MEDICINE_MOTOR_REACTION_IDS_INVALID` | `4221912` | 422 | Validation error | motor_reaction_ids contains invalid uuid |
| `MEDICINE_CREATE_FAILED` | `4001913` | 400 | Bad Request | failed to create medicine |
| `MEDICINE_UPDATE_FAILED` | `4001914` | 400 | Bad Request | failed to update medicine |
| `MEDICINE_DELETE_FAILED` | `4001915` | 400 | Bad Request | failed to delete medicine |
| `MEDICINE_LIST_FAILED` | `4001916` | 400 | Bad Request | failed to list medicine data |
### Master Settings (Module `20`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `MASTER_SETTINGS_NOT_FOUND` | `4042001` | 404 | Not found | master settings not found |
| `MASTER_SETTINGS_INVALID_PAYLOAD` | `4222002` | 422 | Validation error | invalid master settings payload |
| `MASTER_SETTINGS_INVALID_JSON` | `4002003` | 400 | Bad Request | invalid json payload |
| `MASTER_SETTINGS_INVALID_UUID` | `4222004` | 422 | Validation error | master settings uuid is invalid |
| `MASTER_SETTINGS_ID_REQUIRED` | `4222005` | 422 | Validation error | master settings id is required |
| `MASTER_SETTINGS_ID_MISMATCH` | `4222006` | 422 | Validation error | master settings id mismatch |
| `MASTER_SETTINGS_ATTRIBUTES_REQUIRED` | `4222007` | 422 | Validation error | at least one master settings attribute must be provided |
| `MASTER_SETTINGS_COMPANY_NAME_REQUIRED` | `4222008` | 422 | Validation error | company_name is required |
| `MASTER_SETTINGS_LOGO_FILE_INVALID` | `4222009` | 422 | Validation error | logo file uuid is invalid |
| `MASTER_SETTINGS_COVER_FILE_INVALID` | `4222010` | 422 | Validation error | cover file uuid is invalid |
| `MASTER_SETTINGS_CREATE_FAILED` | `4002011` | 400 | Bad Request | failed to create master settings |
| `MASTER_SETTINGS_UPDATE_FAILED` | `4002012` | 400 | Bad Request | failed to update master settings |
| `MASTER_SETTINGS_DELETE_FAILED` | `4002013` | 400 | Bad Request | failed to delete master settings |
| `MASTER_SETTINGS_GET_FAILED` | `4002014` | 400 | Bad Request | failed to get master settings |
| `MASTER_SETTINGS_MICROSOFT_ENTRA_UPSERT_FAILED` | `4002015` | 400 | Bad Request | failed to upsert microsoft entra sso settings |
### Branding (Module `28`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `BRANDING_LOGO_FETCH_FAILED` | `4002801` | 400 | Bad Request | failed to fetch branding logo |
| `BRANDING_COVER_FETCH_FAILED` | `4002802` | 400 | Bad Request | failed to fetch branding cover |
| `BRANDING_EMAIL_LOGO_FETCH_FAILED` | `4002803` | 400 | Bad Request | failed to fetch branding email logo |
| `BRANDING_CONTENT_FETCH_FAILED` | `4002804` | 400 | Bad Request | failed to fetch branding content |
| `BRANDING_STORAGE_UNAVAILABLE` | `5032805` | 503 | Internal server error | branding storage service unavailable |
| `BRANDING_FILE_MANAGER_UNAVAILABLE` | `5032806` | 503 | Internal server error | branding file manager service unavailable |
### File Manager (Module `21`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FILE_MANAGER_STORAGE_UNAVAILABLE` | `5032101` | 503 | Storage unavailable | storage service is unavailable |
| `FILE_MANAGER_QUEUE_UNAVAILABLE` | `5032102` | 503 | Queue unavailable | queue service is unavailable |
| `FILE_MANAGER_UPLOAD_INTENT_UNAVAILABLE` | `5032103` | 503 | Upload intent unavailable | upload intent service is unavailable |
| `FILE_MANAGER_ATTACHMENT_SERVICE_UNAVAILABLE` | `5032104` | 503 | Attachment service unavailable | attachment service is unavailable |
| `FILE_MANAGER_FOLDER_NOT_FOUND` | `4042105` | 404 | Not found | folder not found |
| `FILE_MANAGER_FILE_NOT_FOUND` | `4042106` | 404 | Not found | file not found |
| `FILE_MANAGER_ATTACHMENT_NOT_FOUND` | `4042107` | 404 | Not found | attachment not found |
| `FILE_MANAGER_UPLOAD_INTENT_NOT_FOUND` | `4042108` | 404 | Not found | upload intent not found |
| `FILE_MANAGER_STATUS_CONFLICT` | `4092109` | 409 | Conflict | file status conflict |
| `FILE_MANAGER_UPLOAD_INTENT_CONFLICT` | `4092110` | 409 | Conflict | upload intent conflict |
| `FILE_MANAGER_VALIDATION_FAILED` | `4222111` | 422 | Validation error | file manager validation failed |
| `FILE_MANAGER_ACTION_FAILED` | `4002112` | 400 | Bad Request | file manager action failed |
### OPC Data (Module `22`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `OPC_NOT_FOUND` | `4042201` | 404 | Not found | opc not found |
| `OPC_RELATION_DELETE_ERROR` | `4092202` | 409 | Conflict | cannot delete opc due to active relations |
| `OPC_INVALID_PAYLOAD` | `4222203` | 422 | Validation error | invalid opc payload |
| `OPC_INVALID_JSON` | `4002204` | 400 | Bad Request | invalid json payload |
| `OPC_INVALID_UUID` | `4222205` | 422 | Validation error | opc uuid is invalid |
| `OPC_ID_REQUIRED` | `4222206` | 422 | Validation error | opc id is required |
| `OPC_ID_MISMATCH` | `4222207` | 422 | Validation error | opc id mismatch |
| `OPC_ATTRIBUTES_REQUIRED` | `4222208` | 422 | Validation error | at least one opc attribute must be provided |
| `OPC_TITLE_REQUIRED` | `4222209` | 422 | Validation error | opc title is required |
| `OPC_CREATE_FAILED` | `4002210` | 400 | Bad Request | failed to create opc |
| `OPC_UPDATE_FAILED` | `4002211` | 400 | Bad Request | failed to update opc |
| `OPC_DELETE_FAILED` | `4002212` | 400 | Bad Request | failed to delete opc |
| `OPC_LIST_FAILED` | `4002213` | 400 | Bad Request | failed to list opc data |
### Patient Data (Module `23`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `PATIENT_DATA_NOT_FOUND` | `4042301` | 404 | Not found | patient data not found |
| `PATIENT_DATA_RELATION_DELETE_ERROR` | `4092302` | 409 | Conflict | cannot delete patient data due to active relations |
| `PATIENT_DATA_INVALID_PAYLOAD` | `4222303` | 422 | Validation error | invalid patient data payload |
| `PATIENT_DATA_INVALID_JSON` | `4002304` | 400 | Bad Request | invalid json payload |
| `PATIENT_DATA_INVALID_UUID` | `4222305` | 422 | Validation error | patient data uuid is invalid |
| `PATIENT_DATA_ID_REQUIRED` | `4222306` | 422 | Validation error | patient data id is required |
| `PATIENT_DATA_ID_MISMATCH` | `4222307` | 422 | Validation error | patient data id mismatch |
| `PATIENT_DATA_ATTRIBUTES_REQUIRED` | `4222308` | 422 | Validation error | at least one patient data attribute must be provided |
| `PATIENT_DATA_FAMILY_NAME_REQUIRED` | `4222309` | 422 | Validation error | family_name is required |
| `PATIENT_DATA_FIRST_NAME_REQUIRED` | `4222310` | 422 | Validation error | first_name is required |
| `PATIENT_DATA_BIRTH_DATE_INVALID` | `4222311` | 422 | Validation error | birth_date is invalid |
| `PATIENT_DATA_GENDER_INVALID` | `4222312` | 422 | Validation error | gender is invalid |
| `PATIENT_DATA_OPC_ID_INVALID` | `4222313` | 422 | Validation error | opc_id is invalid |
| `PATIENT_DATA_INSURANCE_PATIENT_DATA_ID_INVALID` | `4222314` | 422 | Validation error | insurance_patient_data_id is invalid |
| `PATIENT_DATA_CREATE_FAILED` | `4002315` | 400 | Bad Request | failed to create patient data |
| `PATIENT_DATA_UPDATE_FAILED` | `4002316` | 400 | Bad Request | failed to update patient data |
| `PATIENT_DATA_DELETE_FAILED` | `4002317` | 400 | Bad Request | failed to delete patient data |
| `PATIENT_DATA_LIST_FAILED` | `4002318` | 400 | Bad Request | failed to list patient data |
### Insurance Patient Data (Module `24`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `INSURANCE_PATIENT_DATA_NOT_FOUND` | `4042401` | 404 | Not found | insurance patient data not found |
| `INSURANCE_PATIENT_DATA_RELATION_DELETE_ERROR` | `4092402` | 409 | Conflict | cannot delete insurance patient data due to active relations |
| `INSURANCE_PATIENT_DATA_INVALID_PAYLOAD` | `4222403` | 422 | Validation error | invalid insurance patient data payload |
| `INSURANCE_PATIENT_DATA_INVALID_JSON` | `4002404` | 400 | Bad Request | invalid json payload |
| `INSURANCE_PATIENT_DATA_INVALID_UUID` | `4222405` | 422 | Validation error | insurance patient data uuid is invalid |
| `INSURANCE_PATIENT_DATA_ID_REQUIRED` | `4222406` | 422 | Validation error | insurance patient data id is required |
| `INSURANCE_PATIENT_DATA_ID_MISMATCH` | `4222407` | 422 | Validation error | insurance patient data id mismatch |
| `INSURANCE_PATIENT_DATA_ATTRIBUTES_REQUIRED` | `4222408` | 422 | Validation error | at least one insurance patient data attribute must be provided |
| `INSURANCE_PATIENT_DATA_HEALTH_INSURANCE_ID_INVALID` | `4222409` | 422 | Validation error | health_insurance_companies_id is invalid |
| `INSURANCE_PATIENT_DATA_FEDERAL_STATE_ID_INVALID` | `4222410` | 422 | Validation error | federal_state_id is invalid |
| `INSURANCE_PATIENT_DATA_CREATE_FAILED` | `4002411` | 400 | Bad Request | failed to create insurance patient data |
| `INSURANCE_PATIENT_DATA_UPDATE_FAILED` | `4002412` | 400 | Bad Request | failed to update insurance patient data |
| `INSURANCE_PATIENT_DATA_DELETE_FAILED` | `4002413` | 400 | Bad Request | failed to delete insurance patient data |
| `INSURANCE_PATIENT_DATA_LIST_FAILED` | `4002414` | 400 | Bad Request | failed to list insurance patient data |
### Role (Module `25`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `ROLE_NOT_FOUND` | `4042501` | 404 | Not found | role not found |
| `ROLE_PERMISSION_NOT_FOUND` | `4042502` | 404 | Not found | permission not found |
| `ROLE_RELATION_DELETE_ERROR` | `4092503` | 409 | Conflict | cannot delete role due to active relations |
| `ROLE_INVALID_PAYLOAD` | `4222504` | 422 | Validation error | invalid role payload |
| `ROLE_INVALID_JSON` | `4002505` | 400 | Bad Request | invalid json payload |
| `ROLE_INVALID_UUID` | `4222506` | 422 | Validation error | role uuid is invalid |
| `ROLE_INVALID_PERMISSION_UUID` | `4222507` | 422 | Validation error | permission uuid is invalid |
| `ROLE_ID_REQUIRED` | `4222508` | 422 | Validation error | role id is required |
| `ROLE_ID_MISMATCH` | `4222509` | 422 | Validation error | role id mismatch |
| `ROLE_ATTRIBUTES_REQUIRED` | `4222510` | 422 | Validation error | at least one role attribute must be provided |
| `ROLE_NAME_REQUIRED` | `4222511` | 422 | Validation error | role name is required |
| `ROLE_PERMISSION_ID_REQUIRED` | `4222512` | 422 | Validation error | permission_id is required |
| `ROLE_PERMISSION_IDS_EMPTY` | `4222513` | 422 | Validation error | permission_ids must not be empty |
| `ROLE_CREATE_FAILED` | `4002514` | 400 | Bad Request | failed to create role |
| `ROLE_UPDATE_FAILED` | `4002515` | 400 | Bad Request | failed to update role |
| `ROLE_DELETE_FAILED` | `4002516` | 400 | Bad Request | failed to delete role |
| `ROLE_ASSIGN_FAILED` | `4002517` | 400 | Bad Request | failed to assign permission |
| `ROLE_REMOVE_FAILED` | `4002518` | 400 | Bad Request | failed to remove permission |
| `ROLE_LIST_FAILED` | `4002519` | 400 | Bad Request | failed to list role data |
| `ROLE_PERMISSION_LIST_FAILED` | `4002520` | 400 | Bad Request | failed to list permission data |
| `ROLE_PERMISSION_UPDATE_FAILED` | `4002521` | 400 | Bad Request | failed to update permission |
| `ROLE_PERMISSION_REQUIRES_PIN_REQUIRED` | `4222522` | 422 | Validation error | requires_pin is required |
### Flight Data (Module `26`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `FLIGHT_DATA_NOT_FOUND` | `4042601` | 404 | Not found | flight data not found |
| `FLIGHT_DATA_INVALID_PAYLOAD` | `4222602` | 422 | Validation error | invalid flight data payload |
| `FLIGHT_DATA_INVALID_JSON` | `4002603` | 400 | Bad Request | invalid json payload |
| `FLIGHT_DATA_INVALID_PATH_UUID` | `4222604` | 422 | Validation error | path uuid is invalid |
| `FLIGHT_DATA_MISSION_ID_REQUIRED` | `4222605` | 422 | Validation error | mission_id is required |
| `FLIGHT_DATA_PILOT_ID_REQUIRED` | `4222606` | 422 | Validation error | pilot_id is required |
| `FLIGHT_DATA_INVALID_UUID` | `4222607` | 422 | Validation error | uuid is invalid |
| `FLIGHT_DATA_INVALID_RFC3339` | `4222608` | 422 | Validation error | datetime must be RFC3339 |
| `FLIGHT_DATA_ORIGIN_LOCATION_REFERENCE` | `4222609` | 422 | Validation error | origin location reference is invalid |
| `FLIGHT_DATA_DESTINATION_LOCATION_REFERENCE` | `4222610` | 422 | Validation error | destination location reference is invalid |
| `FLIGHT_DATA_REQUIRED` | `4002611` | 400 | Bad Request | required flight data is missing |
| `FLIGHT_DATA_CREATE_FAILED` | `4002612` | 400 | Bad Request | failed to create flight data |
| `FLIGHT_DATA_GET_FAILED` | `4002613` | 400 | Bad Request | failed to get flight data |
| `FLIGHT_DATA_AFTER_FLIGHT_INCOMPLETE` | `4222616` | 422 | Validation error | cannot submit after-flight: flight data is incomplete |
| `FLIGHT_DATA_CREATE_BLOCKED` | `4092618` | 409 | Conflict | flight data cannot be created because fm report already exists for this flight |
| `FLIGHT_DATA_AFTER_FLIGHT_LOCKED` | `4232618` | 423 | Locked | flight data is locked because after flight inspection already exists |
| `FLIGHT_DATA_LOCKED` | `4232619` | 423 | Locked | flight data is locked because fm report is completed |
### Air Rescuer Checklist (Module `27`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `AIR_RESCUER_CHECKLIST_NOT_FOUND` | `4042701` | 404 | Not found | air rescuer checklist not found |
| `AIR_RESCUER_CHECKLIST_ITEM_NOT_FOUND` | `4042702` | 404 | Not found | air rescuer checklist item not found |
| `AIR_RESCUER_CHECKLIST_INVALID_PAYLOAD` | `4222703` | 422 | Validation error | invalid air rescuer checklist payload |
| `AIR_RESCUER_CHECKLIST_INVALID_JSON` | `4002704` | 400 | Bad Request | invalid json payload |
| `AIR_RESCUER_CHECKLIST_INVALID_UUID` | `4222705` | 422 | Validation error | air rescuer checklist uuid is invalid |
| `AIR_RESCUER_CHECKLIST_ID_MISMATCH` | `4222706` | 422 | Validation error | id does not match path uuid |
| `AIR_RESCUER_CHECKLIST_SCOPE_INVALID` | `4222707` | 422 | Validation error | scope_code is invalid |
| `AIR_RESCUER_CHECKLIST_SCOPE_REQUIRED` | `4222708` | 422 | Validation error | scope_code is required |
| `AIR_RESCUER_CHECKLIST_TITLE_REQUIRED` | `4222709` | 422 | Validation error | title is required |
| `AIR_RESCUER_CHECKLIST_NAME_REQUIRED` | `4222710` | 422 | Validation error | name is required |
| `AIR_RESCUER_CHECKLIST_BASE_NOT_FOUND` | `4042711` | 404 | Not found | base not found |
| `AIR_RESCUER_CHECKLIST_CREATE_FAILED` | `4002712` | 400 | Bad Request | failed to create air rescuer checklist |
| `AIR_RESCUER_CHECKLIST_UPDATE_FAILED` | `4002713` | 400 | Bad Request | failed to update air rescuer checklist |
| `AIR_RESCUER_CHECKLIST_DELETE_FAILED` | `4002714` | 400 | Bad Request | failed to delete air rescuer checklist |
| `AIR_RESCUER_CHECKLIST_LIST_FAILED` | `4002715` | 400 | Bad Request | failed to list air rescuer checklist |
| `AIR_RESCUER_CHECKLIST_REORDER_FAILED` | `4002716` | 400 | Bad Request | failed to reorder air rescuer checklist items |
| `AIR_RESCUER_CHECKLIST_VALIDATION_FAILED` | `4222717` | 422 | Validation error | air rescuer checklist validation failed |
### After Flight Inspection (Module `37`)
| Code | Error Code | HTTP | Title | Message |
|---|---:|---:|---|---|
| `AFTER_FLIGHT_INVALID_ID` | `4223701` | 422 | Validation error | flight_inspection_id is invalid |
| `AFTER_FLIGHT_FLIGHT_INSPECTION_NOT_FOUND` | `4043702` | 404 | Not found | flight inspection not found |
| `AFTER_FLIGHT_INSPECTION_NOT_FOUND` | `4043703` | 404 | Not found | after flight inspection not found |
| `AFTER_FLIGHT_CAPABILITIES_UNAVAILABLE` | `4223704` | 422 | Validation error | helicopter capabilities could not be resolved |
| `AFTER_FLIGHT_NR1_REQUIRED` | `4223705` | 422 | Validation error | oil_engine_nr1_checked is required for this helicopter |
| `AFTER_FLIGHT_NR1_NOT_AVAILABLE` | `4223706` | 422 | Validation error | NR1 capability not available on this helicopter |
| `AFTER_FLIGHT_NR2_REQUIRED` | `4223707` | 422 | Validation error | oil_engine_nr2_checked is required for this helicopter |
| `AFTER_FLIGHT_NR2_NOT_AVAILABLE` | `4223708` | 422 | Validation error | NR2 capability not available on this helicopter |
| `AFTER_FLIGHT_LH_REQUIRED` | `4223709` | 422 | Validation error | hydraulic_lh_checked is required for this helicopter |
| `AFTER_FLIGHT_LH_NOT_AVAILABLE` | `4223710` | 422 | Validation error | LH capability not available on this helicopter |
| `AFTER_FLIGHT_RH_REQUIRED` | `4223711` | 422 | Validation error | hydraulic_rh_checked is required for this helicopter |
| `AFTER_FLIGHT_RH_NOT_AVAILABLE` | `4223712` | 422 | Validation error | RH capability not available on this helicopter |
| `AFTER_FLIGHT_MGB_REQUIRED` | `4223713` | 422 | Validation error | oil_transmission_mgb_checked is required for this helicopter |
| `AFTER_FLIGHT_MGB_NOT_AVAILABLE` | `4223714` | 422 | Validation error | MGB capability not available on this helicopter |
| `AFTER_FLIGHT_IGB_REQUIRED` | `4223715` | 422 | Validation error | oil_transmission_igb_checked is required for this helicopter |
| `AFTER_FLIGHT_IGB_NOT_AVAILABLE` | `4223716` | 422 | Validation error | IGB capability not available on this helicopter |
| `AFTER_FLIGHT_TGB_REQUIRED` | `4223717` | 422 | Validation error | oil_transmission_tgb_checked is required for this helicopter |
| `AFTER_FLIGHT_TGB_NOT_AVAILABLE` | `4223718` | 422 | Validation error | TGB capability not available on this helicopter |
| `AFTER_FLIGHT_INVALID_JSON` | `4003719` | 400 | Bad Request | invalid json payload |
## Notes
- `title` dan `detail` dapat berubah mengikuti copywriting; jangan dijadikan kontrak parsing.
- `code` dan `error_code` harus dianggap stabil sebagai kontrak integrasi BE↔FE.
- Uniqueness `code` dan `error_code` divalidasi lewat test registry di package [apperrorsx](../internal/shared/pkg/apperrorsx) (`registry_test.go`).
- Sumber definisi tunggal: [codes_defs.go](../internal/shared/pkg/apperrorsx/codes_defs.go) (konstanta `Code*`, `Case*`, dan variable `Err*` + `Registry`).
- Jika menambah error baru: daftarkan di `codes_defs.go` (konstanta + variable `Err*`) lalu masukkan ke `Registry`, kemudian update tabel di catalog ini.

View File

@@ -0,0 +1,124 @@
# File Lifecycle and S3 Cleanup
## Lifecycle States (DB source of truth)
`file_files.lifecycle_status`:
- `temp`: uploaded but not attached
- `active`: referenced by one or more business entities
- `orphan_pending`: currently unreferenced, waiting grace period
- `deleted`: backend cleanup has deleted object(s) and marked terminal state
Supporting timestamps:
- `created_at`: existing file creation time
- `attached_at`: set when file becomes active
- `orphaned_at`: set when file becomes unreferenced
- `lifecycle_deleted_at`: set when cleanup marks lifecycle `deleted`
Notes:
- Existing `status` column (`uploaded/processing/validated/ready/trashed`) remains for processing/trash flow.
- `deleted_at` is still used by trash semantics. Cleanup lifecycle uses `lifecycle_deleted_at`.
## S3 Tags
Single tag key: `gc`
- `gc=temp`: temporary uploads
- `gc=keep`: active or protected files
- `gc=delete`: optional marker immediately before backend delete
S3 tags are markers only. DB lifecycle state and DB reference checks are authoritative.
## Cleanup Job
Scheduler: API process loop (`startFileManagerLifecycleCleanupLoop`).
Behavior:
1. Query DB for `orphan_pending` files where `orphaned_at <= now - ORPHAN_GRACE_PERIOD_DAYS`.
2. Process in batches (`FILE_CLEANUP_BATCH_SIZE`).
3. Re-check DB references per file before deletion.
4. If referenced again: mark `active`, set/keep `gc=keep`.
5. If still unreferenced:
- optional `gc=delete` tag
- delete original and thumbnail S3 objects
- mark lifecycle `deleted` and set `lifecycle_deleted_at`
6. Per-file error handling; one failure does not stop the batch.
The job is idempotent and retry-safe.
## Environment Variables
- `ENABLE_S3_TAGGING` (default `true`)
- `ENABLE_FILE_CLEANUP_JOB` (default `false`)
- `TEMP_FILE_TTL_DAYS` (default `7`)
- `ORPHAN_GRACE_PERIOD_DAYS` (default `30`)
- `FILE_CLEANUP_BATCH_SIZE` (default `100`)
- `FILE_CLEANUP_DRY_RUN` (default `true`)
## Backfill (Dry-Run First)
Command:
```bash
go run ./cmd/file-lifecycle-backfill
```
Optional:
- `BACKFILL_DRY_RUN=true|false` (default `true`)
Output report includes:
- active files
- probable orphan files
- missing S3 key
- invalid references
- skipped records
## Required IAM Permissions
Minimum required:
- `s3:PutObjectTagging`
- `s3:GetObjectTagging`
- `s3:DeleteObject`
- `s3:GetObject`
- `s3:HeadObject`
Only if infra automation updates lifecycle rules:
- `s3:PutLifecycleConfiguration`
## S3 Lifecycle Rules (Manual if no IaC)
Because this repository does not define Terraform/CDK/CloudFormation for S3 bucket lifecycle, configure lifecycle rules in your infra/deployment stack:
1. Rule `gc=temp`:
- Filter: object tag `gc=temp`
- Expire after `TEMP_FILE_TTL_DAYS` (recommended default 7 days)
2. Optional rule `gc=delete`:
- Filter: object tag `gc=delete`
- Expire after 1 day
Do not use lifecycle expiration as the orphan grace mechanism. Grace period is enforced by DB `orphaned_at`.
## Rollout Plan
1. Deploy schema + code with defaults (`cleanup job off`, `dry-run on`).
2. Enable tagging for new uploads (`ENABLE_S3_TAGGING=true`).
3. Validate attach/detach transitions in logs.
4. Run backfill dry-run and review report.
5. Enable cleanup job in dry-run mode.
6. Switch cleanup dry-run off after validation.
7. Apply S3 lifecycle rules for `gc=temp` (and optional `gc=delete`).
## Rollback Plan
1. Set `ENABLE_FILE_CLEANUP_JOB=false`.
2. Set `FILE_CLEANUP_DRY_RUN=true`.
3. Optionally set `ENABLE_S3_TAGGING=false`.
4. Pause/remove lifecycle rule `gc=delete` if enabled.

View File

@@ -0,0 +1,143 @@
# File Manager Aircraft Image Flow
This document explains the upload + create flow specifically for aircraft images.
## Purpose
This flow is used to upload aircraft images (drag-and-drop) and persist file metadata into a forced folder hierarchy based on the aircraft designation.
Main differences compared to the regular file flow:
- dedicated upload endpoint: `POST /api/v1/file-manager/files/upload-aircraft-image`
- dedicated create endpoint: `POST /api/v1/file-manager/files/create-aircraft-image`
- `content_type` must be `image/*`
- target folder is forced to: `__system_root_files__ > aircraft > <aircraft designation>`
- `aircraft_uuid` is required in the create request per item
## Prerequisites
1. User is logged in and has `file_manager.create` permission.
2. Frontend has per-file data: `name`, `content_type`, `size_bytes`, and binary file.
## Step 1 - Request Upload Intent (Aircraft)
Endpoint:
- `POST /api/v1/file-manager/files/upload-aircraft-image`
Request body (bulk, minimum 1 item):
```json
{
"data": [
{
"type": "file_manager_file_upload_intent",
"attributes": {
"name": "aircraft-front.jpg",
"content_type": "image/jpeg",
"size_bytes": 42170
}
}
]
}
```
Important validation rules in this endpoint:
- `data` must be an array
- `type` must be `file_manager_file_upload_intent`
- `name` is required
- `content_type` is required and must start with `image/`
- `size_bytes >= 0`
Per-item response (partial success is possible), FE receives:
- `upload_intent_uuid`
- `upload_url`
- `method` (`PUT`)
- `required_headers`
- object info (`bucket`, `key`)
- `expires_at`, `expires_in_seconds`
## Step 2 - Upload Binary Directly to Object Storage
For each item with `success: true` from step 1:
1. take `upload_url`
2. perform `PUT` binary file to that URL
3. send headers according to `required_headers`
Example:
```javascript
await fetch(upload_url, {
method: "PUT",
headers: {
"Content-Type": file.type
},
body: file
});
```
Notes:
- 1 file = 1 `PUT` request
- parallel upload is allowed with a concurrency limit
- files that fail `PUT` must not be sent to the create step
## Step 3 - Create File Record (Aircraft)
Endpoint:
- `POST /api/v1/file-manager/files/create-aircraft-image`
Request body:
```json
{
"data": [
{
"type": "file_manager_file_create",
"attributes": {
"upload_intent_uuid": "019d8215-c622-7576-86bd-3ddeecaf4867",
"aircraft_uuid": "019d8215-c799-7209-9237-2e2f5d0f8709",
"name": "aircraft-front.jpg"
}
}
]
}
```
Important validation rules:
- `data` must be an array
- `type` must be `file_manager_file_create` or `file_manager_file`
- `upload_intent_uuid` is required and must be a valid UUID
- `aircraft_uuid` is required and must be a valid UUID
- `name` is required
Aircraft-specific behavior:
- backend loads aircraft by `aircraft_uuid` and reads its `designation`
- backend resolves/creates folder hierarchy:
- `__system_root_files__` (system root)
- `aircraft` under system root
- `<designation>` under `aircraft` (example: `OE-XHZ`)
- final file is created inside `<designation>` folder
- `folder_id` from payload is ignored in this endpoint because folder is derived from aircraft designation
## Step 4 - Backend Process During Create
For each valid item:
1. backend validates and loads aircraft from `aircraft_uuid`
2. backend resolves/creates `__system_root_files__ > aircraft > <designation>`
3. backend validates uploaded object from `upload_intent_uuid`
4. backend creates file node in `<designation>` folder
5. file status enters normal file manager lifecycle (`uploaded -> processing -> validated -> ready` or failed)
## Status Codes
- `201`: all create items succeed
- `207`: partial success (mixed success and failure)
- `404`: aircraft not found (per-item in bulk response)
- `422`: payload validation error
- `400`: invalid JSON payload
- `503`: aircraft service unavailable
## FE Integration Summary
1. call `upload-aircraft-image`
2. upload binary to storage via presigned URL
3. call `create-aircraft-image` only for items with successful `PUT`
4. store resulting file `id` for reference in aircraft module

View File

@@ -0,0 +1,73 @@
# File Manager
# Attachment (Step by Step)
## A. Tambah Attachment
### Endpoint
- `POST /api/v1/file-manager/attachments/create`
### FE kirim request ini
```json
{
"data": {
"type": "file_manager_attachment_create",
"attributes": {
"file_id": "file-uuid",
"ref_type": "helicopter",
"ref_id": "business-entity-uuid",
"category": "document",
"sort_order": 0,
"is_primary": false
}
}
}
```
Keterangan:
- `file_id`, `ref_type`, `ref_id` wajib
- `category` opsional
- `sort_order` opsional (default 0)
- `is_primary` opsional (default false)
## B. Lihat Attachment by Reference
### Endpoint
- `GET /api/v1/file-manager/attachments/get-all?ref_type=<type>&ref_id=<id>`
### Contoh
- `GET /api/v1/file-manager/attachments/get-all?ref_type=helicopter&ref_id=business-entity-uuid`
Catatan urutan default:
- jika query `sort` tidak dikirim, urutan default adalah `sort_order ASC, created_at DESC`
- artinya untuk `sort_order` yang sama, attachment terbaru tampil lebih dulu
## C. Detail Attachment
### Endpoint
- `GET /api/v1/file-manager/attachments/get/:uuid`
## D. Hapus Attachment (Detach)
### Endpoint
- `DELETE /api/v1/file-manager/attachments/delete/:uuid`
### Contoh response
```json
{
"data": {
"type": "file_manager_attachment_delete",
"attributes": {
"detached": true
}
}
}
```
## Ringkasan
1. create relation file -> entity bisnis
2. list by `ref_type + ref_id`
3. get detail relation
4. delete relation (detach)

View File

@@ -0,0 +1,62 @@
# File Manager Base Image Flow
This document explains upload + create flow specifically for base images.
## Purpose
This flow uploads base images and persists file metadata into a forced folder hierarchy:
- `__system_root_files__ > bases > <base-type> > <base-name>`
Main differences compared to regular file flow:
- dedicated upload endpoint: `POST /api/v1/file-manager/files/upload-base-image`
- dedicated create endpoint: `POST /api/v1/file-manager/files/create-base-image`
- `content_type` must be `image/*`
- `base_uuid` is required in the create request per item
- create step performs hard replace of existing files in target folder before creating new file
## Step 1 - Request Upload Intent (Base)
Endpoint:
- `POST /api/v1/file-manager/files/upload-base-image`
Validation highlights:
- `type` must be `file_manager_file_upload_intent`
- `name` required
- `content_type` required and must start with `image/`
- `size_bytes >= 0`
## Step 2 - Upload Binary to Object Storage
For each successful intent, frontend uploads binary with `PUT` to `upload_url`.
## Step 3 - Create File Record (Base)
Endpoint:
- `POST /api/v1/file-manager/files/create-base-image`
Validation highlights:
- `type` must be `file_manager_file_create` or `file_manager_file`
- `upload_intent_uuid` required and valid UUID
- `base_uuid` required and valid UUID
- `name` required
Backend behavior:
1. load base from `base_uuid`
2. resolve `base-type` from base category (e.g. `hems` or `regular`)
3. resolve/create folder path `__system_root_files__ > bases > <base-type> > <base-name>`
4. hard replace: delete + purge all existing files in that target folder
5. create file node from upload intent
Hard replace cleanup target:
- file row in DB
- original object in S3
- thumbnail object in S3 (if exists)
- related attachment metadata

View File

@@ -0,0 +1,829 @@
# File Manager Collabora / WOPI Flow
Dokumen ini menjelaskan alur frontend untuk membuka editor Collabora lewat backend WOPI.
Prinsip utama:
1. Frontend hanya memanggil App API.
2. Frontend tidak pernah memanggil endpoint `/wopi/...` secara langsung.
3. Backend yang mengurus token, proof, S3, lock, autosave, dan finalisasi.
Tambahan penting untuk flow helicopter template:
1. hasil `create-from-template` akan dibuat sebagai file manager file biasa, bukan template
2. backend juga akan memastikan file itu masuk ke `helicopter_files` section `fpwb`
3. kalau frontend membuka template yang sama untuk `takeover_id` yang sama, backend akan reuse file existing bila sudah ada
4. saat child `fpwb` baru berhasil dibuat, backend akan kirim SSE agar frontend bisa langsung append item baru ke list
5. kalau file hasil template punya `takeover_id`, endpoint `GET /api/v1/helicopter-files/get/{helicopter_id}` dan `GET /api/v1/helicopter-files/list` akan menyembunyikan item itu dari user yang tidak punya akses takeover tersebut
## Endpoint Yang Dipakai Frontend
### 1) Buat file baru dari template
`POST /api/v1/file-manager/files/create-from-template`
Request:
```json
{
"data": {
"type": "file_manager_file_create_from_template",
"attributes": {
"template_uuid": "019f0386-0a00-7a4a-a2d8-30afc0e0c3ad",
"helicopter_id": "019e8713-aa19-7fb7-a510-a0332dc69697",
"takeover_id": "019f0386-0a00-7a4a-a2d8-30afc0e0c3af",
"name": "collabora-draft.docx"
}
}
}
```
Response:
```json
{
"data": {
"file_id": "019f0a12-1111-7abc-8def-222222222222",
"collabora_action": "https://collabora.example.com/.../WOPISrc=...",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"access_token_ttl": 1800000
}
}
```
### 2) Buka editor untuk file existing
`GET /api/v1/file-manager/files/{fileId}/edit-session`
Response shape sama:
```json
{
"data": {
"file_id": "019f0a12-1111-7abc-8def-222222222222",
"collabora_action": "https://collabora.example.com/.../WOPISrc=...",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"access_token_ttl": 1800000
}
}
```
### 3) Ambil daftar helicopter files
`GET /api/v1/helicopter-files/get/{helicopter_id}`
Endpoint ini yang dipakai frontend untuk melihat child file yang muncul di helicopter file collection.
Untuk hasil `create-from-template`, item baru akan muncul di:
1. `data.attributes.files.prepare`
2. section backend = `fpwb`
3. secara UI biasanya ditampilkan sebagai area Flight Preparation and WB
### 4) Dengarkan event child baru
`GET /api/v1/helicopter-files/events?helicopter_id={helicopter_id}`
Stream ini dipakai supaya frontend bisa langsung append child baru tanpa reload penuh.
Event name:
`helicopter.file.created`
### SSE dapet dari mana?
SSE ini bukan datang dari Collabora, bukan dari browser local state, dan bukan dari endpoint `/wopi/...`.
Sumbernya adalah backend App API:
1. frontend subscribe ke `GET /api/v1/helicopter-files/events?helicopter_id={helicopter_id}`
2. route ini masuk ke handler SSE backend
3. backend hanya akan mengirim event ke user yang sedang subscribe
4. event difilter lagi dengan `helicopter_id` query param
Kapan event dipublish:
1. frontend memanggil `POST /api/v1/file-manager/files/create-from-template`
2. backend membuat atau reuse file manager file
3. backend memastikan ada row `helicopter_files` di section `fpwb`
4. kalau row `helicopter_files` itu baru dibuat saat request ini, backend publish event `helicopter.file.created`
5. stream `/api/v1/helicopter-files/events` mengirim event itu ke frontend yang subscribe
Artinya:
1. kalau child `fpwb` sudah ada dari sebelumnya, backend tidak akan publish event create lagi
2. kalau frontend telat subscribe, fallback paling aman tetap re-fetch `GET /api/v1/helicopter-files/get/{helicopter_id}`
3. SSE ini hanya untuk sinkronisasi UI realtime, bukan source of truth utama
## Flow Create From Template
### Step 1 - Frontend minta editor session
Frontend mengirim `template_uuid`, `helicopter_id`, `name`, dan boleh menambahkan `takeover_id`.
`template_uuid` harus mengarah ke `helicopter_file.helicopter_file_id` dari helicopter yang sama dengan `helicopter_id`, bukan ke file takeover biasa.
Backend akan menjalankan alur seperti ini:
```text
create-from-template
-> cek user session
-> cek template memang milik helicopter itu
-> jika ada takeover_id:
-> cek apakah kombinasi template + takeover sudah punya file existing
-> jika sudah ada:
-> reuse file existing
-> pastikan metadata template + takeover tersimpan
-> pastikan ada child helicopter_files di section fpwb
-> jika child baru dibuat, publish SSE helicopter.file.created
-> mint WOPI token
-> balikin Collabora action URL
-> jika belum ada:
-> buat draft file baru dari template
-> copy object template ke key final file baru
-> simpan metadata template + takeover
-> pastikan ada child helicopter_files di section fpwb
-> jika child baru dibuat, publish SSE helicopter.file.created
-> mint WOPI token
-> balikin Collabora action URL
-> jika takeover_id tidak ada:
-> buat draft file baru dari template
-> copy object template ke key final file baru
-> simpan metadata template
-> pastikan ada child helicopter_files di section fpwb
-> jika child baru dibuat, publish SSE helicopter.file.created
-> mint WOPI token
-> balikin Collabora action URL
```
Catatan untuk step 9:
1. SSE dipublish oleh backend App API pada request `create-from-template`
2. jadi FE yang sedang membuka halaman helicopter files bisa langsung menerima event tanpa menunggu save dari Collabora
3. source of truth event tetap berasal dari proses create child row di backend
### Step 2 - Frontend simpan response
Frontend wajib menyimpan:
1. `file_id`
2. `collabora_action`
3. `access_token`
4. `access_token_ttl` dalam milidetik
`file_id` dipakai sebagai join key untuk session berikutnya.
`file_id` ini juga bisa dikirim lagi saat frontend create takeover lewat field `edited_template_file_ids`.
Kalau tujuan frontend adalah menampilkan child di helicopter files:
1. `file_id` adalah ID file manager file
2. `helicopter_file_id` tidak ada di response create-from-template
3. `helicopter_file_id` akan muncul lewat:
`GET /api/v1/helicopter-files/get/{helicopter_id}`
atau SSE `helicopter.file.created`
### Step 3 - Frontend buka Collabora
Frontend tidak boleh hanya redirect atau `window.open(collabora_action)` begitu saja.
`collabora_action` harus dibuka dengan `POST` form yang ikut mengirim:
1. `access_token`
2. `access_token_ttl` dalam milidetik
Ini penting karena backend WOPI membaca token dari query `access_token` atau header bearer pada request WOPI yang datang dari Collabora.
Kalau frontend hanya membuka `collabora_action` tanpa token bootstrap, backend akan mengembalikan error seperti:
1. `401 FILE_MANAGER_WOPI_INVALID_TOKEN`
2. `token is malformed: token contains an invalid number of segments`
Frontend membuka editor memakai:
1. `collabora_action` sebagai `form action`
2. `access_token` sebagai hidden input
3. `access_token_ttl` sebagai hidden input
Rule praktis:
1. jangan pernah memanggil `/wopi/...` dari browser
2. jangan pernah expose S3 URL atau credential ke frontend
3. gunakan `collabora_action` sebagai base URL editor
4. jangan buka `collabora_action` tanpa submit `access_token`
## File Akan Muncul Di Mana
Setelah `create-from-template` sukses, hasil akhirnya ada di dua tempat berbeda yang perlu dibedakan:
### 1) File manager file
Ini adalah object file utama yang dibuka oleh Collabora.
Identifier utamanya:
1. `file_id`
Endpoint yang relevan:
1. `GET /api/v1/file-manager/files/{fileId}/edit-session`
2. WOPI internal backend `/wopi/files/{fileId}` dan `/wopi/files/{fileId}/contents`
### 2) Helicopter file child
Ini adalah row bisnis yang membuat file tadi muncul di halaman helicopter files.
Lokasinya:
1. `GET /api/v1/helicopter-files/get/{helicopter_id}`
2. response path `data.attributes.files.prepare`
3. section row = `fpwb`
Field penting item collection:
1. `helicopter_file_id`
2. `attachment.id`
3. `attachment.file_name`
Catatan:
1. child ini bukan parent folder file manager
2. child ini adalah row di tabel `helicopter_files`
3. relasinya menunjuk ke file hasil template lewat `source_file_id`
## Contoh Integrasi Next.js
Bagian ini menunjukkan contoh yang lengkap untuk Next.js App Router.
Asumsi:
1. user sudah login ke App API
2. frontend memanggil backend App API, bukan endpoint `/wopi/...`
3. backend mengembalikan `file_id`, `collabora_action`, `access_token`, dan `access_token_ttl`
### Tipe response
```ts
export type CollaboraSessionResponse = {
data: {
file_id: string
collabora_action: string
access_token: string
access_token_ttl: number
}
}
```
### Server action atau API helper
Contoh helper untuk memanggil backend `create-from-template`.
```ts
// app/lib/file-manager.ts
export type CreateFromTemplateInput = {
templateUuid: string
helicopterId: string
takeoverId?: string
name: string
}
export type CollaboraSessionResponse = {
data: {
file_id: string
collabora_action: string
access_token: string
access_token_ttl: number
}
}
export async function createFromTemplate(
input: CreateFromTemplateInput,
accessToken: string,
): Promise<CollaboraSessionResponse> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/file-manager/files/create-from-template`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
data: {
type: "file_manager_file_create_from_template",
attributes: {
template_uuid: input.templateUuid,
helicopter_id: input.helicopterId,
takeover_id: input.takeoverId,
name: input.name,
},
},
}),
cache: "no-store",
},
)
if (!response.ok) {
throw new Error(`Create from template failed with status ${response.status}`)
}
return response.json()
}
```
Contoh helper untuk membuka file existing:
```ts
// app/lib/file-manager.ts
export async function getEditSession(
fileId: string,
accessToken: string,
): Promise<CollaboraSessionResponse> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/file-manager/files/${fileId}/edit-session`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
cache: "no-store",
},
)
if (!response.ok) {
throw new Error(`Edit session failed with status ${response.status}`)
}
return response.json()
}
```
### Komponen client untuk bootstrap Collabora
Komponen ini membuat form hidden dan auto-submit ke `collabora_action`.
```tsx
// app/components/collabora-launcher.tsx
"use client"
import { useEffect, useRef } from "react"
type Props = {
collaboraAction: string
accessToken: string
accessTokenTTL: number // milliseconds
target?: string
}
export function CollaboraLauncher({
collaboraAction,
accessToken,
accessTokenTTL,
target = "collabora-editor",
}: Props) {
const formRef = useRef<HTMLFormElement | null>(null)
useEffect(() => {
formRef.current?.submit()
}, [])
return (
<>
<form ref={formRef} action={collaboraAction} method="post" target={target}>
<input type="hidden" name="access_token" value={accessToken} />
<input type="hidden" name="access_token_ttl" value={String(accessTokenTTL)} />
</form>
<iframe
name={target}
title="Collabora Editor"
className="h-[calc(100vh-120px)] w-full border-0"
/>
</>
)
}
```
### Halaman Next.js untuk create from template
Contoh berikut memakai App Router dan menampilkan editor setelah session berhasil dibuat.
```tsx
// app/file-manager/templates/[templateId]/open/page.tsx
import { CollaboraLauncher } from "@/app/components/collabora-launcher"
import { createFromTemplate } from "@/app/lib/file-manager"
import { cookies } from "next/headers"
type PageProps = {
params: Promise<{
templateId: string
}>
searchParams: Promise<{
helicopterId?: string
name?: string
}>
}
export default async function OpenTemplatePage({ params, searchParams }: PageProps) {
const { templateId } = await params
const { helicopterId, name } = await searchParams
if (!helicopterId || !name) {
throw new Error("helicopterId and name are required")
}
const cookieStore = await cookies()
const accessToken = cookieStore.get("access_token")?.value
if (!accessToken) {
throw new Error("Missing app access token")
}
const session = await createFromTemplate(
{
templateUuid: templateId,
helicopterId,
name,
},
accessToken,
)
return (
<CollaboraLauncher
collaboraAction={session.data.collabora_action}
accessToken={session.data.access_token}
accessTokenTTL={session.data.access_token_ttl}
/>
)
}
```
### Halaman Next.js untuk file existing
```tsx
// app/file-manager/files/[fileId]/edit/page.tsx
import { CollaboraLauncher } from "@/app/components/collabora-launcher"
import { getEditSession } from "@/app/lib/file-manager"
import { cookies } from "next/headers"
type PageProps = {
params: Promise<{
fileId: string
}>
}
export default async function EditFilePage({ params }: PageProps) {
const { fileId } = await params
const cookieStore = await cookies()
const accessToken = cookieStore.get("access_token")?.value
if (!accessToken) {
throw new Error("Missing app access token")
}
const session = await getEditSession(fileId, accessToken)
return (
<CollaboraLauncher
collaboraAction={session.data.collabora_action}
accessToken={session.data.access_token}
accessTokenTTL={session.data.access_token_ttl}
/>
)
}
```
### Kenapa tidak cukup pakai `window.open(collabora_action)`?
Karena response backend memisahkan:
1. `collabora_action`
2. `access_token`
3. `access_token_ttl`
Sedangkan backend WOPI membutuhkan token valid saat Collabora mulai memanggil:
1. `GET /wopi/files/{fileId}`
2. `GET /wopi/files/{fileId}/contents`
Kalau frontend hanya membuka URL editor tanpa mengirim hidden form fields di atas, Collabora akan tetap mencoba request WOPI, tetapi backend akan membaca token kosong atau malformed.
### Contoh yang salah
```tsx
window.open(session.data.collabora_action, "_blank")
```
atau:
```tsx
<iframe src={session.data.collabora_action} />
```
Kedua contoh di atas tidak mengirim `access_token` bootstrap.
### Contoh yang benar
```tsx
<form action={session.data.collabora_action} method="post" target="collabora-editor">
<input type="hidden" name="access_token" value={session.data.access_token} />
<input
type="hidden"
name="access_token_ttl"
value={String(session.data.access_token_ttl)}
/>
</form>
```
### Step 4 - Autosave berjalan di backend
Setelah editor terbuka:
1. Collabora memanggil `GET /wopi/files/{fileId}`
2. Collabora memanggil `GET /wopi/files/{fileId}/contents`
3. saat autosave, Collabora memanggil `POST /wopi/files/{fileId}/contents`
4. saat close / exit save, Collabora mengirim lifecycle headers
Frontend tidak perlu mengurus autosave secara manual.
## Flow Tampilan Child Di Helicopter Files
Kalau FE punya halaman detail helicopter files yang memanggil:
`GET /api/v1/helicopter-files/get/{helicopter_id}`
maka flow lengkapnya begini:
### Step 1 - Load data awal
Frontend fetch:
`GET /api/v1/helicopter-files/get/{helicopter_id}`
Lalu render:
1. `data.attributes.files.before`
2. `data.attributes.files.prepare`
3. `data.attributes.files.after`
Hasil `create-from-template` akan masuk ke array:
`data.attributes.files.prepare`
### Step 2 - Subscribe SSE
Saat halaman helicopter files terbuka, frontend subscribe:
`GET /api/v1/helicopter-files/events?helicopter_id={helicopter_id}`
Event yang perlu didengar:
`helicopter.file.created`
Penjelasan singkat:
1. endpoint ini adalah stream SSE dari backend App API
2. backend akan keep connection tetap hidup dan mengirim heartbeat
3. event akan dikirim ketika backend membuat child `helicopter_file` baru untuk helicopter itu
4. event ini tidak tergantung pada autosave WOPI
Contoh payload:
```json
{
"helicopter_id": "019dfb9e-db0c-7eaf-b701-8b4928e62d24",
"helicopter_file_id": "019f2000-aaaa-7bbb-8ccc-111111111111",
"file_id": "019f2000-dddd-7eee-8fff-222222222222",
"section": "fpwb",
"position": 3,
"is_mandatory": false,
"attachment_pending": true,
"name": "collabora-draft.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"updated_at": "2026-07-01T08:30:00Z"
}
```
### Step 3 - Append child baru ke state
Kalau frontend menerima event:
1. cocokkan `helicopter_id`
2. cek `section === "fpwb"`
3. append ke collection `prepare`
4. hindari duplikasi dengan key `helicopter_file_id`
Contoh Next.js client hook:
```tsx
"use client"
import { useEffect } from "react"
type HelicopterFileCreatedEvent = {
helicopter_id: string
helicopter_file_id: string
file_id: string
section: string
position: number
is_mandatory: boolean
attachment_pending: boolean
name: string
mime_type: string
updated_at: string
}
type PrepareItem = {
helicopter_file_id: string
section: string
position: number
is_mandatory: boolean
attachment_pending: boolean
attachment: {
id: string
file_name: string
download_url: string
}
created_at: string
updated_at: string
}
export function useHelicopterFileCreatedSSE(
helicopterId: string,
onCreated: (event: HelicopterFileCreatedEvent) => void,
) {
useEffect(() => {
const url = new URL(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/helicopter-files/events`,
)
url.searchParams.set("helicopter_id", helicopterId)
const source = new EventSource(url.toString(), { withCredentials: true })
const handler = (raw: MessageEvent<string>) => {
const payload = JSON.parse(raw.data) as HelicopterFileCreatedEvent
onCreated(payload)
}
source.addEventListener("helicopter.file.created", handler as EventListener)
return () => {
source.removeEventListener(
"helicopter.file.created",
handler as EventListener,
)
source.close()
}
}, [helicopterId, onCreated])
}
```
Contoh append ke state:
```tsx
setPrepareFiles((current) => {
if (current.some((item) => item.helicopter_file_id === event.helicopter_file_id)) {
return current
}
const nextItem = {
helicopter_file_id: event.helicopter_file_id,
section: event.section,
position: event.position,
is_mandatory: event.is_mandatory,
attachment_pending: event.attachment_pending,
attachment: {
id: event.file_id,
file_name: event.name,
download_url: "",
},
created_at: event.updated_at,
updated_at: event.updated_at,
}
return [...current, nextItem].sort((a, b) => a.position - b.position)
})
```
Catatan:
1. saat event baru datang, `attachment_pending` bisa masih `true`
2. artinya child row sudah ada, tetapi attachment final mungkin belum ter-resolve penuh
3. kalau FE butuh shape yang benar-benar final, aman juga untuk re-fetch sekali ke:
`GET /api/v1/helicopter-files/get/{helicopter_id}`
### Step 5 - Refresh token saat TTL hampir habis
Token WOPI bersifat short-lived.
Saat token mendekati habis, frontend harus:
1. memanggil ulang `GET /api/v1/file-manager/files/{fileId}/edit-session` untuk file yang sudah ada
Lalu replace token yang dipakai untuk membuka editor.
Catatan:
1. setelah file pertama sukses dibuat, jangan panggil `create-from-template` lagi hanya untuk refresh token
2. untuk reopen atau refresh editor, gunakan `edit-session`
3. `create-from-template` dipakai untuk bootstrap file baru atau reuse by `template + takeover`
## Flow Edit File Existing
### Step 1 - Frontend minta edit session
Frontend hanya mengirim `fileId`.
Backend akan:
1. load file node
2. cari takeover asal file
3. verifikasi user punya akses ke takeover
4. mint access token baru
5. balikin Collabora action URL
### Step 2 - Frontend buka editor
Frontend membuka editor pakai response yang sama seperti flow create-from-template.
## Lifecycle Yang Perlu Dipahami Frontend
### Saat editor dibuka
Backend sudah membuat record file node.
Artinya:
1. tidak ada lagi langkah create file saat save
2. Collabora tinggal overwrite file yang sama
3. `fileId` tetap sama selama sesi itu
### Saat user mengetik
Collabora akan autosave ke backend melalui WOPI.
Frontend tidak perlu memanggil endpoint save sendiri.
### Saat user menutup editor
Jika Collabora mengirim exit-save:
1. backend menandai file `final`
2. backend attach relasi takeover bila perlu saat create takeover menerima `edited_template_file_ids`
3. frontend cukup refresh data list / detail
4. child `fpwb` tetap berada di helicopter files karena row bisnisnya sudah dibuat sejak create-from-template
### Jika user menutup tanpa perubahan
File tetap berada di status draft dan bisa dibersihkan oleh job cleanup jika stale.
## Permission Model
### App API
App API tetap memakai session auth normal.
Frontend harus sudah login sebelum memanggil:
1. `POST /api/v1/file-manager/files/create-from-template`
2. `GET /api/v1/file-manager/files/{fileId}/edit-session`
### WOPI API
WOPI API memakai:
1. `access_token`
2. proof key validation
3. permission claim `read` atau `write`
Frontend tidak perlu menambahkan permission manual ke WOPI request. Itu sudah ditangani backend saat mint token.
## Error Handling Singkat
Semua error App API mengikuti envelope `apperrorsx` yang sudah dipakai backend:
1. `status`
2. `code`
3. `error_code`
4. `title`
5. `message`
Untuk frontend, patokan utamanya adalah `code` dan `error_code`, bukan hanya HTTP status.
Mapping singkat yang dipakai di flow ini:
1. `403` App API: `FORBIDDEN_ACCESS` / `USER_UNAUTHORIZED` saat user tidak boleh akses takeover atau file.
2. `401/403` WOPI: `FILE_MANAGER_WOPI_INVALID_TOKEN`, `FILE_MANAGER_WOPI_INVALID_PROOF`, atau `FILE_MANAGER_WOPI_PERMISSION_DENIED`.
3. `409` save WOPI: `FILE_MANAGER_WOPI_LOCK_CONFLICT` atau `FILE_MANAGER_WOPI_STALE_TIMESTAMP`.
4. `422` input invalid: `VALIDATION_ERROR` atau `FILE_MANAGER_WOPI_VALIDATION_FAILED`.
5. `404` file/takeover missing: `DATA_NOT_FOUND`, `FILE_MANAGER_FILE_NOT_FOUND`, atau `FILE_MANAGER_WOPI_FILE_NOT_FOUND`.
Catatan:
1. WOPI save conflict kadang tetap mengembalikan body protokol khusus seperti `COOLStatusCode: 1010`.
2. Untuk WOPI, frontend tetap tidak memanggil endpoint tersebut langsung; yang perlu di-handle adalah response dari backend saat membuka editor.
## Hal Yang Harus Dihindari
1. Jangan panggil `/wopi/...` langsung dari frontend.
2. Jangan kirim credential S3 ke browser.
3. Jangan hardcode file path di frontend.
4. Jangan anggap token WOPI berlaku lama.
5. Jangan reuse token file A untuk file B.
6. Jangan pakai `create-from-template` berulang-ulang hanya untuk refresh editor file yang sama.

View File

@@ -0,0 +1,241 @@
# File Manager
# Delete, Restore, Purge
## Ringkasan Endpoint Utama
1. `DELETE /api/v1/file-manager/delete` untuk soft delete bulk (file/folder)
2. `PATCH /api/v1/file-manager/restore` untuk restore bulk (file/folder) ke lokasi asal
3. `DELETE /api/v1/file-manager/purge-delete` untuk hard delete bulk (file/folder)
4. `GET /api/v1/file-manager/trash/get-all` untuk list trash merged
## Catatan Kontrak
1. Tidak ada endpoint delete single.
2. Tidak ada endpoint restore single.
3. Gunakan endpoint bulk untuk delete/restore/purge.
## Step 1 - Soft Delete Node (Bulk)
### Endpoint
- `DELETE /api/v1/file-manager/delete`
### Tujuan
- soft delete file/folder (masuk trash)
- jika folder di-delete, subtree (child folder + child file) ikut ke-trash
### Contoh request
```json
{
"data": [
{ "uuid": "folder-uuid" },
{ "uuid": "file-uuid" }
]
}
```
### Contoh response
```json
{
"data": [
{
"index": 0,
"success": true,
"resource": {
"type": "file_manager_delete_result",
"id": "folder-uuid",
"attributes": {
"node_type": "folder",
"status": "trashed"
}
}
},
{
"index": 1,
"success": true,
"resource": {
"type": "file_manager_delete_result",
"id": "file-uuid",
"attributes": {
"node_type": "file",
"status": "trashed"
}
}
}
],
"meta": {
"total": 2,
"success": 2,
"failed": 0,
"partial_success": false
}
}
```
## Step 3 - Lihat Trash
### Endpoint
- `GET /api/v1/file-manager/trash/get-all`
### Tujuan
- menampilkan data trash merged (folder + file)
### Contoh response
```json
{
"data": [
{
"type": "file_manager_trash_node",
"id": "folder-uuid",
"attributes": {
"node_type": "folder",
"name": "Documents",
"path_cache": "/Documents",
"trashed_at": "2026-04-01T10:00:00Z"
}
},
{
"type": "file_manager_trash_node",
"id": "file-uuid",
"attributes": {
"node_type": "file",
"name": "LOGO.png",
"extension": "png",
"size_bytes": 100988,
"mime_type": "image/png",
"trashed_at": "2026-04-01T10:00:00Z"
}
}
],
"meta": {
"page_number": 1,
"page_size": 20,
"folders": 1,
"files": 1,
"total": 2
}
}
```
## Step 4 - Restore Node (Bulk, kembali ke lokasi asal)
### Endpoint
- `PATCH /api/v1/file-manager/restore`
### Contoh request
```json
{
"data": [{ "uuid": "folder-uuid" }, { "uuid": "file-uuid" }]
}
```
Aturan:
- restore tidak menerima `destination_folder_uuid` lagi
- node akan kembali ke lokasi asal (parent/folder saat sebelum di-trash)
- jika lokasi asal root, field `location` tidak dimunculkan
### Contoh response
```json
{
"data": [
{
"index": 0,
"success": true,
"resource": {
"type": "file_manager_restore_result",
"id": "folder-uuid",
"attributes": {
"node_type": "folder",
"status": "ready"
}
}
},
{
"index": 1,
"success": true,
"resource": {
"type": "file_manager_restore_result",
"id": "file-uuid",
"attributes": {
"node_type": "file",
"status": "ready",
"location": {
"folder_id": "folder-uuid-optional"
}
}
}
}
],
"meta": {
"total": 2,
"success": 2,
"failed": 0,
"partial_success": false
}
}
```
## Step 5 - Purge Node (Hard Delete)
### Endpoint
- `DELETE /api/v1/file-manager/purge-delete` (bulk)
### Tujuan
- hapus permanen file atau folder (UUID bisa keduanya)
- kalau UUID folder: subtree ikut dihapus permanen
- kalau UUID file: file dihapus permanen
### Contoh request bulk
```json
{
"data": [
{ "uuid": "folder-uuid" },
{ "uuid": "file-uuid" }
]
}
```
### Contoh response
```json
{
"data": [
{
"index": 0,
"success": true,
"resource": {
"type": "file_manager_purge_result",
"id": "folder-uuid",
"attributes": {
"node_type": "folder",
"status": "purged"
}
}
},
{
"index": 1,
"success": true,
"resource": {
"type": "file_manager_purge_result",
"id": "file-uuid",
"attributes": {
"node_type": "file",
"status": "purged"
}
}
}
],
"meta": {
"total": 2,
"success": 2,
"failed": 0,
"partial_success": false
}
}
```

230
docs/file-manager-get.md Normal file
View File

@@ -0,0 +1,230 @@
# File Manager - GET Data (Step by Step)
# Daftar Endpoint
## Ringkasan Endpoint Utama
1. `GET /file-manager/get-all` untuk explorer merged (root/child)
2. `GET /file-manager/trash/get-all` untuk trash merged
3. `GET /file-manager/files/get/:uuid` untuk detail file
4. `GET /file-manager/folders/get/:uuid` untuk detail folder
## Endpoint Removed (tidak tersedia lagi)
1. `GET /file-manager/files/get-all`
2. `GET /file-manager/folders/get-all`
3. `GET /file-manager/files/get-all-trash`
4. `GET /file-manager/folders/get-all-trash`
## Step 1 - Ambil Explorer Root
### Endpoint
- `GET /api/v1/file-manager/get-all`
### Tujuan
- menampilkan isi root explorer (merged folder + file).
### Contoh response
```json
{
"data": [
{
"type": "file_manager_node",
"id": "folder-uuid",
"attributes": {
"node_type": "folder",
"name": "Documents",
"status": "ready",
"path_cache": "/Documents",
"created_at": "2026-03-31T08:52:03Z",
"updated_at": "2026-03-31T08:52:03Z"
}
},
{
"type": "file_manager_node",
"id": "file-uuid",
"attributes": {
"node_type": "file",
"name": "LOGO.png",
"extension": "png",
"size_bytes": 100988,
"mime_type": "image/png",
"download_url": "https://...",
"status": "ready",
"created_at": "2026-03-31T09:30:31Z",
"updated_at": "2026-03-31T09:30:31Z"
}
}
],
"meta": {
"is_root": true,
"folders": 1,
"files": 1,
"total": 2
}
}
```
## Step 2 - Buka Isi Folder
### Endpoint
- `GET /api/v1/file-manager/get-all?parent_uuid=<folder_uuid>`
### Tujuan
- menampilkan child langsung dari folder yang dipilih.
### Contoh response
```json
{
"data": [
{
"type": "file_manager_node",
"id": "inside-file-uuid",
"attributes": {
"node_type": "file",
"parent_id": "folder-uuid",
"name": "inside-folder.png",
"extension": "png",
"size_bytes": 42170,
"mime_type": "image/png",
"download_url": "https://...",
"status": "ready",
"created_at": "2026-04-05T07:29:33Z",
"updated_at": "2026-04-05T07:33:44Z"
}
}
],
"meta": {
"is_root": false,
"parent_uuid": "folder-uuid",
"breadcrumb": [
{
"id": "folder-uuid-level-1",
"name": "Documents"
},
{
"id": "folder-uuid",
"name": "Project A"
}
],
"folders": 0,
"files": 1,
"total": 1
}
}
```
## Step 2b - Ambil Data Trash (Merged)
### Endpoint
- `GET /api/v1/file-manager/trash/get-all`
### Tujuan
- menampilkan daftar node yang sedang trashed (folder + file).
### Contoh response
```json
{
"data": [
{
"type": "file_manager_trash_node",
"id": "folder-uuid",
"attributes": {
"node_type": "folder",
"name": "Documents",
"path_cache": "/Documents",
"trashed_at": "2026-04-01T10:00:00Z"
}
},
{
"type": "file_manager_trash_node",
"id": "file-uuid",
"attributes": {
"node_type": "file",
"name": "logo.png",
"extension": "png",
"size_bytes": 42170,
"mime_type": "image/png",
"download_url": "https://...",
"trashed_at": "2026-04-01T10:00:00Z"
}
}
],
"meta": {
"total": 2
}
}
```
## Step 3 - Ambil Detail File
### Endpoint
- `GET /api/v1/file-manager/files/get/:uuid`
### Tujuan
- ambil detail information dari satu file untuk panel/detail page.
### Contoh response
```json
{
"data": {
"type": "file_manager_file",
"id": "file-uuid",
"attributes": {
"name": "spidey.png",
"name_normalized": "spidey.png",
"extension": "png",
"size_bytes": 42170,
"mime_type": "image/png",
"download_url": "https://...",
"status": "ready",
"location": {
"folder_id": "folder-uuid"
},
"processing": {
"processing_started_at": "2026-04-05T07:29:34Z",
"processed_at": "2026-04-05T07:29:34Z",
"validated_at": "2026-04-05T07:29:34Z"
},
"audit": {
"created_at": "2026-04-05T07:29:33Z",
"created_by": "019d194e-f595-7d5f-a5bf-3609231ba43d",
"updated_at": "2026-04-05T07:33:44Z",
"updated_by": "019d194e-f595-7d5f-a5bf-3609231ba43d"
}
}
}
}
```
## Step 4 - Ambil Detail Folder
### Endpoint
- `GET /api/v1/file-manager/folders/get/:uuid`
### Tujuan
- ambil detail satu folder (mis. untuk panel detail folder/breadcrumb context).
### Contoh response
```json
{
"data": {
"type": "file_manager_folder",
"id": "folder-uuid",
"attributes": {
"name": "Documents",
"name_normalized": "documents",
"path_cache": "/Documents",
"audit": {
"created_at": "2026-03-31T08:52:03Z",
"created_by": "019d194e-f595-7d5f-a5bf-3609231ba43d",
"updated_at": "2026-03-31T08:52:03Z",
"updated_by": "019d194e-f595-7d5f-a5bf-3609231ba43d"
}
}
}
}
```

View File

@@ -0,0 +1,207 @@
# File Manager — Document Preview
Fitur ini memungkinkan FE untuk menampilkan preview dokumen (PDF, DOCX, XLSX, PPTX) secara inline tanpa user perlu mendownload file terlebih dahulu.
---
## Cara Kerja
### Untuk File PDF
PDF langsung dapat dirender oleh browser. Backend mengembalikan `preview_url` yang menunjuk ke file aslinya (sama dengan `download_url`). Tidak ada proses konversi.
### Untuk File Office (DOCX, XLSX, PPTX, DOC, XLS, PPT)
Dokumen dikonversi ke PDF secara otomatis oleh background worker saat file selesai diupload. Proses ini menggunakan **Gotenberg** (container yang menjalankan LibreOffice secara headless). Hasil konversi disimpan di S3 sebagai file terpisah (`.preview.pdf`).
### Untuk File Lainnya (Image, Video, dll)
`preview_url` tidak diisi (kosong). FE menangani preview image menggunakan `download_url` seperti sebelumnya.
---
## Flow Lengkap
```
1. FE upload file (existing flow — tidak berubah)
2. Background worker memproses file:
- Image → generate thumbnail (existing)
- PDF → tidak perlu konversi, preview_url = original URL
- Office → konversi ke PDF via Gotenberg → simpan preview di S3 → update DB
3. File status menjadi "ready"
4. FE ambil detail file:
GET /api/v1/file-manager/files/get/{uuid}
→ response berisi preview_url
5. FE render preview_url di iframe/tab baru
```
---
## Endpoint
### GET /api/v1/file-manager/files/get/{uuid}
Endpoint existing. Response sekarang memiliki field tambahan `preview_url` di dalam `attributes`.
#### Contoh Response (PDF atau Office doc yang sudah diproses)
```json
{
"data": {
"type": "file_manager_file",
"id": "019d8215-c622-7576-86bd-3ddeecaf4867",
"attributes": {
"name": "laporan.docx",
"extension": "docx",
"size_bytes": 204800,
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"status": "ready",
"download_url": "https://s3.../laporan.docx?X-Amz-Signature=...",
"preview_url": "https://s3.../laporan.preview.pdf?X-Amz-Signature=...",
"audit": {
"created_at": "2026-07-06T10:00:00Z",
"updated_at": "2026-07-06T10:00:05Z"
}
}
}
}
```
#### Contoh Response (file masih diproses atau konversi gagal)
```json
{
"data": {
"type": "file_manager_file",
"id": "019d8215-c622-7576-86bd-3ddeecaf4867",
"attributes": {
"name": "laporan.docx",
"status": "processing",
"download_url": "https://s3.../laporan.docx?X-Amz-Signature=...",
"preview_url": ""
}
}
}
```
---
## Logika `preview_url` di Backend
| Kondisi | `preview_url` |
|---------|--------------|
| File PDF | Sama dengan `download_url` |
| File Office, sudah dikonversi | Presigned URL ke `.preview.pdf` di S3 |
| File Office, belum/gagal dikonversi | Kosong (`""`) |
| File image / tipe lainnya | Kosong (`""`) |
---
## Error Handling
Jika konversi via Gotenberg gagal:
- **Error 4xx dari Gotenberg** (dokumen tidak valid/tidak didukung): file status menjadi `failed`. FE harus handle kondisi ini dengan menampilkan pesan error atau fallback ke tombol download.
- **Error jaringan atau 5xx dari Gotenberg** (Gotenberg sedang down): worker akan retry otomatis. File status tetap `processing` sampai berhasil atau melebihi batas retry.
---
## Panduan untuk FE
```js
const file = await api.getFile(uuid)
const { preview_url, download_url, status, mime_type } = file.data.attributes
if (preview_url) {
// Render preview di iframe
openPreviewModal(preview_url)
} else if (status === 'processing') {
// Tampilkan loading / tunggu SSE event
showProcessingIndicator()
} else if (status === 'failed') {
// Tampilkan error, tawarkan download langsung
showError('Preview tidak tersedia')
offerDownload(download_url)
} else {
// File type tidak didukung preview (video, archive, dll)
offerDownload(download_url)
}
```
---
## Cara Test di Local (Tanpa Frontend)
### Prasyarat
1. Gotenberg harus jalan:
```bash
docker-compose up gotenberg
```
2. File worker harus jalan:
```bash
docker-compose up file-worker
```
3. Pastikan `.env.local` sudah ada `GOTENBERG_URL=http://localhost:3000`
### Langkah Test
```bash
# 1. Upload file DOCX (step upload + create seperti biasa)
# 2. Tunggu file status menjadi "ready" (cek via SSE atau polling GET)
# 3. Ambil detail file
curl -H "Authorization: Bearer <token>" \
http://localhost:8080/api/v1/file-manager/files/get/<uuid>
# 4. Salin nilai preview_url dari response
# 5. Buka preview_url di browser — PDF harus tampil
```
---
## Konfigurasi
### docker-compose.yml
```yaml
gotenberg:
image: gotenberg/gotenberg:8
ports:
- "3000:3000"
restart: unless-stopped
```
### .env.local
```bash
GOTENBERG_URL=http://localhost:3000 # local development
# GOTENBERG_URL=http://gotenberg:3000 # dalam docker-compose network
```
---
## File yang Diubah (OP-1273)
| File | Perubahan |
|------|-----------|
| `migrations/20260706_file_preview_metadata.sql` | ALTER TABLE tambah kolom `preview_*` |
| `docker-compose.yml` | Tambah service Gotenberg |
| `.env.local.example` | Tambah `GOTENBERG_URL` |
| `config/local/config.json` | Tambah section `gotenberg.url` |
| `internal/config/config.go` | Tambah struct `GotenbergConfig` |
| `internal/config/loader.go` | Mapping config Gotenberg |
| `internal/domain/file_manager/model_file.go` | Tambah field `Preview*` di struct `File` |
| `internal/service/file_processing_usecase.go` | Tambah fungsi `convertDocumentIfNeeded`, `callGotenberg`, helper |
| `cmd/worker/file_worker.go` | Wire up `GotenbergURL` ke use case |
| `internal/transport/http/dto/shared/file_manager_file_shared.go` | Tambah field `PreviewURL` di `FileManagerFileDetailAttributes` |
| `internal/transport/http/handlers/file_manager_request_helpers.go` | Tambah `PreviewURL` ke `fileManagerDownloadURLs` dan logic presign |
| `internal/transport/http/handlers/file_manager_file_handler.go` | Update `fileManagerFileDetailResource` untuk set `PreviewURL` |

View File

@@ -0,0 +1,80 @@
# File Manager
# Update dan Move (Step by Step)
## A. Update File
### Endpoint
- `PATCH /api/v1/file-manager/files/update/:uuid`
### FE kirim request ini
```json
{
"data": {
"type": "file_manager_file_update",
"id": "file-uuid",
"attributes": {
"name": "spidey-v2.png",
"folder_id": "folder-uuid-optional"
}
}
}
```
Aturan:
- `data.id` harus sama dengan `:uuid` di path
- minimal isi salah satu: `name` atau `folder_id`
- `folder_id` null/kosong bisa dipakai untuk pindah ke root
## B. Update Folder
### Endpoint
- `PATCH /api/v1/file-manager/folders/update/:uuid`
### FE kirim request ini
```json
{
"data": {
"type": "file_manager_folder_update",
"id": "folder-uuid",
"attributes": {
"name": "Manuals Updated",
"parent_id": "parent-folder-uuid-optional"
}
}
}
```
Aturan:
- `data.id` harus sama dengan `:uuid`
- minimal isi salah satu: `name` atau `parent_id`
## C. Move Node (File/Folder) - Bulk
### Endpoint
- `PATCH /api/v1/file-manager/move`
### FE kirim request ini
```json
{
"data": [
{
"uuid": "node-uuid-file-atau-folder",
"destination_folder_uuid": "folder-uuid-optional"
}
]
}
```
Aturan:
- `destination_folder_uuid` null/kosong = pindah ke root
- `uuid` bisa folder UUID atau file UUID
- bisa kirim banyak item sekaligus (bulk)
## Ringkasan
1. `PATCH /files/update/:uuid` untuk rename/move umum file
2. `PATCH /folders/update/:uuid` untuk rename/move umum folder
3. `PATCH /file-manager/move` untuk move-only file/folder (bulk, boleh 1 item)

288
docs/file-manager-upload.md Normal file
View File

@@ -0,0 +1,288 @@
# File Manager
# Uploading New File
Sebelum hit API, FE ambil data per file:
- `name` (contoh: `spidey.png`)
- `content_type` (contoh: `image/png`)
- `size_bytes` (contoh: `42170`)
## Step 1 - FE minta upload intent ke BE, lalu upload langsung ke storage
## A. Minta Upload Intent ke Backend
### Endpoint
- `POST /api/v1/file-manager/files/upload`
### FE kirim request ini
- Single File
```json
{
"data": [
{
"type": "file_manager_file_upload_intent",
"attributes": {
"name": "image.png",
"content_type": "image/png",
"size_bytes": 123
}
}
]
}
```
- Bulk File
```json
{
"data": [
{
"type": "file_manager_file_upload_intent",
"attributes": {
"name": "image1.png",
"content_type": "image/png",
"size_bytes": 123
}
},
{
"type": "file_manager_file_upload_intent",
"attributes": {
"name": "image2.png",
"content_type": "image/png",
"size_bytes": 456
}
}
]
}
```
### Backend akan balas per file
- `upload_intent_uuid`
- `upload_url`
- `method` (`PUT`)
- `required_headers`
- `object.bucket`
- `object.key`
- `expires_at`
- `expires_in_seconds`
Contoh response:
```json
{
"data": [
{
"index": 0,
"success": true,
"resource": {
"type": "file_manager_file_upload_intent",
"id": "019d8215-c622-7576-86bd-3ddeecaf4867",
"attributes": {
"upload_intent_uuid": "019d8215-c622-7576-86bd-3ddeecaf4867",
"upload_url": "http://localhost:4566/...",
"method": "PUT",
"required_headers": {
"content-type": "image/png"
},
"object": {
"bucket": "wucher-file-dev",
"key": "fm/objects/files/2026/04/12/019d8215c6227a1aa20fc21f99f36584.png"
},
"file": {
"name": "spidey.png",
"mime_type": "image/png",
"size_bytes": 42170
},
"expires_at": "2026-04-12T14:40:58Z",
"expires_in_seconds": 899
}
}
}
],
"meta": {
"total": 1,
"success": 1,
"failed": 0,
"partial_success": false
}
}
```
## B. FE Handle langsung Upload Binary Langsung ke S3
Untuk setiap item `success: true` dari step 1:
1. Ambil `upload_url`
2. Ambil `method`
3. Ambil `required_headers` (jika ada)
4. Lakukan request upload file ke S3
Contoh yang akan dilakukan FE:
```js
await fetch(upload_url, {
method: method, // "PUT"
headers: { "Content-Type": file.type },
body: file,
});
```
atau pseudo
```js
const intents = await api.requestUploadIntents(files) // bulk
const readyToUpload = intents.data.filter(i => i.success)
const uploadResults = await parallelLimit(readyToUpload, 4, putToS3) // paralel
const succeeded = uploadResults.filter(r => r.success)
if (succeeded.length > 0) {
await api.createFilesBulk(succeeded.map(toCreatePayload))
}
```
Catatan penting:
- **1 file = 1 PUT ke S3**
- boleh paralel (disarankan pakai concurrency limit)
- file yang gagal PUT **jangan** dikirim ke step create
## Step 2 - Upload Cancel (opsional)
### Endpoint
- `DELETE /api/v1/file-manager/files/cancel-upload`
### FE kirim request ini
```json
{
"data": [
{
"type": "file_manager_file_cancel_upload",
"attributes": {
"upload_intent_uuid": "019d8215-c622-7576-86bd-3ddeecaf4867"
}
}
]
}
```
### Kapan dipakai
- user sudah request upload intent
- tapi user batal sebelum create
- FE bisa cancel upload intent supaya flow lebih bersih
### Contoh response
```json
{
"data": [
{
"index": 0,
"success": true,
"resource": {
"type": "file_manager_file_cancel_upload_result",
"id": "019d8215-c622-7576-86bd-3ddeecaf4867",
"attributes": {
"status": "canceled"
}
}
}
],
"meta": {
"total": 1,
"success": 1,
"failed": 0,
"partial_success": false
}
}
```
## Step 3 - FE Panggil Create ke Backend (Finalisasi)
Setelah PUT S3 sukses, FE gunakan:
### Endpoint
- `POST /api/v1/file-manager/files/create`
### FE kirim request ini
```json
{
"data": [
{
"type": "file_manager_file_create",
"attributes": {
"upload_intent_uuid": "019d8215-c622-7576-86bd-3ddeecaf4867",
"name": "spidey.png",
"folder_id": null
}
}
]
}
```
Aturan:
- `upload_intent_uuid`: wajib
- `name`: wajib
- `folder_id`: opsional (kalau mau dimasukkan ke folder)
- `null` atau tidak dikirim = root
## Step 4 - Process Create (BE)
Untuk setiap item:
1. Ambil upload intent dari `upload_intent_uuid`
2. Verify object benar-benar ada di storage
3. Verify metadata object (size/content type) sesuai intent
4. Simpan metadata file ke DB
5. Simpan outbox durable
6. Set status awal file menjadi `uploaded`
## Step 5 - Proses Async Lanjutan (Backend)
Setelah status `uploaded`, worker akan lanjut:
- `uploaded -> processing -> validated -> ready`
- atau `uploaded -> processing -> failed`
## Step 6 - FE Handle Response Create
Response create adalah bulk per item.
- Kalau semua sukses: HTTP `201`
- Kalau campuran sukses + gagal: HTTP `207` dengan `partial_success: true`
Contoh sukses:
```json
{
"data": [
{
"index": 0,
"success": true,
"message": "created",
"resource": {
"type": "file_manager_file",
"id": "019d628a-8d92-7949-b355-95de506da3b7",
"attributes": {
"folder_id": "019d4309-4dc8-78be-bb0c-3ebb8f3056ba",
"name": "spidey01.png",
"extension": "png",
"size_bytes": 42170,
"mime_type": "image/png",
"status": "uploaded",
"created_at": "2026-04-06T11:25:40Z",
"updated_at": "2026-04-06T11:25:40Z"
}
}
}
],
"meta": {
"total": 1,
"success": 1,
"failed": 0,
"partial_success": false
}
}
```

View File

@@ -0,0 +1,260 @@
# Flight Ops Locks and Helicopter Usage Sync
Dokumen ini menjelaskan alur data untuk `takeover`, `mission`, `flight_data`, `after_flight_inspection`, `fm_report`, dan `helicopter_usage`.
Tujuan utamanya:
- mencegah perubahan data pada fase yang sudah final
- menjaga `helicopter_usage` sebagai ringkasan yang dihitung dari data sumber
- membuat perilaku backend konsisten untuk frontend dan integrasi lain
## Ringkasan Aturan
1. Saat `after_flight_inspection` sudah dibuat:
- `mission` untuk flight itu tidak boleh dibuat atau di-update lagi
- `flight_data` baru tidak boleh dibuat lagi
- `flight_data` yang sudah ada masih boleh diedit
2. Saat `fm_report` di-complete:
- `flight_data` untuk flight itu terkunci
- `fm_report` menjadi read-only
- `helicopter_usage` baru dihitung ulang dari data sumber
3. Saat `GET /api/v1/fm-reports/get/:flight_id`:
- backend tetap mengirim `helicopter_usage`
- jika row belum ada di database, response tetap mengirim nilai `0`
## Alur Besar
Urutan normalnya:
1. `takeover` dibuat
2. `mission` dibuat
3. `flight_data` diisi dan masih bisa diubah
4. `after_flight_inspection` dibuat
5. `fm_report` draft otomatis tersedia
6. user melengkapi `fm_report`
7. `POST /api/v1/fm-reports/complete/:flight_id`
8. backend menghitung ulang `helicopter_usage`
## Detail Per Modul
### 1) Mission
Mission bisa dibuat dan di-update selama flight belum masuk fase after flight inspection.
Begitu `after_flight_inspection` sudah ada untuk flight tersebut:
- create mission ditolak
- update mission ditolak
Alasan bisnis:
- setelah after flight masuk, mission dianggap sudah masuk fase final
- mission tidak boleh berubah karena akan mempengaruhi rekonsiliasi data flight
Error yang biasanya muncul:
- `MISSION_AFTER_FLIGHT_LOCKED`
Referensi:
- lihat tabel `Mission` di [`docs/error-catalog.md`](./error-catalog.md)
### 2) Flight Data
`flight_data` adalah sumber utama untuk hitungan operasional seperti:
- landing count
- durasi flight
- hook release
- rotor brake cycle
#### Sebelum after flight inspection
- create flight data boleh
- update flight data boleh
- delete flight data boleh
#### Setelah after flight inspection dibuat
- create flight data baru ditolak
- update flight data yang sudah ada masih boleh
- delete flight data yang sudah ada ditolak
#### Setelah FM report di-complete
- update flight data ditolak
- delete flight data ditolak
- data dianggap final
Error yang biasanya muncul:
- `FLIGHT_DATA_CREATE_BLOCKED`
- `FLIGHT_DATA_AFTER_FLIGHT_LOCKED`
- `FLIGHT_DATA_LOCKED`
Referensi:
- lihat tabel `Flight Data` di [`docs/error-catalog.md`](./error-catalog.md)
### 3) After Flight Inspection
`after_flight_inspection` dipakai sebagai penanda bahwa flight sudah masuk fase akhir sebelum FM report di-complete.
Catatan penting:
- create / upsert after flight inspection tidak lagi memicu kalkulasi `helicopter_usage`
- after flight inspection hanya menyimpan hasil inspeksi
- kalkulasi usage dipindahkan ke langkah `fm_report complete`
### 4) FM Report
FM report dibuat sebagai draft setelah after flight inspection masuk ke flow.
#### Sebelum complete
- report masih bisa di-update
- engine metric masih bisa diisi / diubah
- `helicopter_usage` belum dihitung ulang oleh backend
#### Setelah complete
- report menjadi read-only
- update FM report ditolak
- `completed_at` dan `completed_by` disimpan
- backend memicu refresh `helicopter_usage`
Endpoint yang dipakai:
- `POST /api/v1/fm-reports/complete/:flight_id`
Error yang biasanya muncul:
- `FM_REPORT_LOCKED`
- `FM_REPORT_COMPLETE_FAILED`
Referensi:
- lihat tabel `FM Report` di [`docs/error-catalog.md`](./error-catalog.md)
### 5) Helicopter Usage
`helicopter_usage` sekarang diperlakukan sebagai summary record.
Karakteristiknya:
- dihitung dari data sumber
- bukan angka yang diisi manual untuk flow normal
- tetap bisa di-maintain manual lewat CRUD admin/backfill kalau diperlukan
#### Sumber data kalkulasi
- `total_landing` = sum `flight_data.landing_count`
- `total_airframe_hours` = sum durasi flight
- `total_airframe_cycles` = count flight data
- `total_hook_release` = sum `flight_data.hook_releases`
- `total_rotor_brake_cycle` = sum `flight_data.rotor_brake_cycle`
- `total_flight_report` = count report yang punya helicopter terkait
- metric engine diambil dari field FM report dan dijumlahkan
#### Kapan dihitung
Kalkulasi ulang dilakukan saat:
- `fm_report` di-complete
Kalkulasi tidak dilakukan lagi saat:
- create / update after flight inspection
- create / update / delete flight data
- draft FM report di-upsert
## Response `GET FM Report`
Endpoint:
- `GET /api/v1/fm-reports/get/:flight_id`
Response akan selalu mengirim field `helicopter_usage`.
Perilaku response:
- jika row `helicopter_usage` ada di database, isi nilainya dari row tersebut
- jika row belum ada, backend tetap mengirim object `helicopter_usage` dengan semua angka `0`
Contoh bentuk response:
```json
{
"data": {
"type": "fm_report",
"id": "report-id",
"attributes": {
"flight_id": "flight-id",
"completed_at": null,
"helicopter_usage": {
"type": "helicopter_usage",
"id": "",
"attributes": {
"helicopter_id": "helicopter-id",
"total_landing": 0,
"total_airframe_hours": 0,
"total_airframe_cycles": 0,
"total_engine_1_hours": 0,
"total_engine_1_gpc_ng_n1": 0,
"total_engine_1_ptc_nf_n2": 0,
"total_engine_1_ccc": 0,
"total_engine_2_hours": 0,
"total_engine_2_gpc_ng_n1": 0,
"total_engine_2_ptc_nf_n2": 0,
"total_engine_2_ccc": 0,
"total_flight_report": 0,
"total_hook_release": 0,
"total_rotor_brake_cycle": 0
}
}
}
}
}
```
## Status yang Perlu Diingat
### Masih editable
- mission sebelum after flight inspection ada
- flight data sebelum FM report di-complete
- FM report sebelum complete
### Sudah locked
- mission setelah after flight inspection ada
- flight data setelah FM report di-complete
- FM report setelah complete
## Dampak ke Frontend
Frontend sebaiknya mengikuti aturan ini:
1. Jika after flight inspection sudah ada:
- sembunyikan atau disable aksi create mission
- sembunyikan atau disable aksi create flight data baru
2. Jika FM report sudah complete:
- disable semua edit flight data
- disable edit FM report
3. Saat menampilkan FM report:
- selalu render section helicopter usage
- jika angka semua `0`, itu berarti belum ada summary yang terhitung atau belum ada data sumber yang selesai
## Error yang Relevan
Lihat katalog lengkap di:
- [`docs/error-catalog.md`](./error-catalog.md)
Error yang paling sering dipakai di alur ini:
- `MISSION_AFTER_FLIGHT_LOCKED`
- `FLIGHT_DATA_CREATE_BLOCKED`
- `FLIGHT_DATA_AFTER_FLIGHT_LOCKED`
- `FLIGHT_DATA_LOCKED`
- `FM_REPORT_LOCKED`
## Catatan Implementasi
- `after_flight_inspection` tidak lagi memicu refresh `helicopter_usage`
- `flight_data` tidak lagi memicu refresh `helicopter_usage`
- refresh `helicopter_usage` hanya terjadi saat `fm_report` di-complete
- `GET FM Report` tetap aman walau row `helicopter_usage` belum ada
## Referensi Endpoint
- `POST /api/v1/mission/create`
- `PATCH /api/v1/mission/update/{flight_id}`
- `POST /api/v1/flight-data/create`
- `PATCH /api/v1/flight-data/update/{id}`
- `DELETE /api/v1/flight-data/delete/{id}`
- `POST /api/v1/after-flight-inspection/create`
- `PATCH /api/v1/after-flight-inspection/update`
- `POST /api/v1/fm-reports/complete/{flight_id}`
- `GET /api/v1/fm-reports/get/{flight_id}`

View File

@@ -0,0 +1,121 @@
# Frontend Flow: Microsoft `ssoSilent` + Backend `/auth/exchange`
This document describes the recommended frontend authentication flow when using:
- Microsoft Entra `ssoSilent` on the frontend
- Backend session tokens (`access` + `refresh`) issued by `/api/v1/auth/exchange`
## Goal
Use Microsoft silent SSO to re-establish identity, then exchange that identity for backend session cookies/tokens used by API requests.
## Prerequisites
- Frontend already integrated with MSAL and can call `ssoSilent`.
- Backend has `POST /api/v1/auth/exchange`.
- User account is already linked/recognized by backend SSO mapping.
- Backend uses cookie-based auth session (`access` + `refresh` cookies).
## High-Level Sequence
1. Frontend attempts to call backend API with current backend access session.
2. If backend responds `401`, frontend calls `POST /api/v1/auth/refresh` once.
3. If refresh fails (`401`), frontend tries Microsoft `ssoSilent`.
4. If `ssoSilent` succeeds, frontend calls `POST /api/v1/auth/exchange`.
5. Backend issues fresh auth cookies. Frontend retries the original API request.
6. If `ssoSilent` fails with `interaction_required`, frontend logs out local app session and redirects user to interactive login.
## Detailed Frontend Logic
## 1) App startup / route guard
1. Try calling a lightweight authenticated endpoint (for example `/api/v1/auth/me`).
2. If response is `200`, continue app normally.
3. If response is `401`, run the recovery flow below.
## 2) Recovery flow for backend `401`
1. Call `POST /api/v1/auth/refresh` once.
2. If refresh is successful (`200`), retry the original request and continue.
3. If refresh fails (`401`), call Microsoft `ssoSilent`.
## 3) On successful `ssoSilent`
The frontend receives a Microsoft auth result (example fields):
- `accessToken`
- `idToken`
- `idTokenClaims`
Send them to backend exchange endpoint:
```http
POST /api/v1/auth/exchange
Content-Type: application/json
```
```json
{
"data": {
"type": "auth_exchange",
"attributes": {
"access_token": "<ms_access_token>",
"id_token": "<ms_id_token>",
"id_token_claims": {
"sub": "...",
"preferred_username": "user@company.com",
"name": "User Name",
"tid": "...",
"oid": "...",
"exp": 1715603600
}
}
}
}
```
Expected behavior:
- `200`: backend sets fresh session cookies -> frontend retries previous API call.
- `401`: identity not linked / invalid token -> treat as not authenticated and move to login.
## 4) On failed `ssoSilent`
If frontend receives:
- `errorCode = "interaction_required"` (or equivalent no-session/no-cookie condition)
Then:
1. Clear local app auth state (user store, cached flags, pending retries).
2. Redirect to interactive login page/flow.
Do not loop `ssoSilent` repeatedly.
## Retry Rules (Important)
- Only attempt each step once per failure chain:
- one `/auth/refresh`
- one `ssoSilent`
- one `/auth/exchange`
- Use a request queue/lock to prevent multiple parallel refresh/exchange attempts.
- If exchange fails, stop retries and force user to login interactively.
## Suggested Error Handling Matrix
- Backend API `401`:
- action: `/auth/refresh`
- `/auth/refresh` `401`:
- action: `ssoSilent`
- `ssoSilent` success:
- action: `/auth/exchange`
- `ssoSilent` `interaction_required`:
- action: clear session + redirect login
- `/auth/exchange` `401`:
- action: clear session + redirect login
## Security Notes
- Do not store backend refresh tokens in JavaScript storage.
- Prefer `HttpOnly`, `Secure`, `SameSite` cookies from backend.
- Avoid logging raw Microsoft tokens in browser console or monitoring payloads.
## Optional UX Improvement
When redirecting to login after `interaction_required`, preserve the intended route:
- Save current path (`returnTo`) and restore it after successful login.

165
docs/mcf-flow.md Normal file
View File

@@ -0,0 +1,165 @@
# Panduan MCF (Maintenance Check Flight): Cara Pakai & Apa yang Terjadi
> Dokumen ini menjelaskan **cara mengelola MCF** sebuah helikopter — mengaktifkan, mengisi/edit draft, menandatangani (sign), membatalkan tanda tangan, dan membatalkan MCF — beserta **apa yang terjadi** di status helikopter, halaman fleet, dan log.
>
> Semua endpoint berawalan `/api/v1` dan butuh login. MCF adalah **entity per-helikopter** dengan lifecycle draft → signed.
---
## 1. Konsep singkat
**MCF** = penerbangan uji (check flight) yang wajib dilakukan setelah perawatan tertentu. Selama helikopter "sedang MCF", statusnya `mcf` di fleet.
Tiga pemain endpoint:
| Endpoint | Tugasnya |
|---|---|
| **`set`** | Yes/No + **tempat kerja draft** (buat, isi, edit data check-flight) |
| **`sign`** | **finalize** (tandatangani hasil) / **unsign** (balik ke draft) |
| **`get` / `get-by-helicopter`** | baca satu MCF / daftar (log, termasuk yang cancelled & signed) |
> **Tidak ada** endpoint `create`/`delete`/`unassign` lagi — semuanya lewat `set` & `sign`.
---
## 2. Status (state) sebuah MCF
| State | Artinya | `completed_at` | `cancelled_at` | Status heli |
|---|---|---|---|---|
| **Draft** | Dibuat/diisi, belum ditandatangani | kosong | kosong | `mcf` |
| **Signed passed** | Sudah tanda tangan, hasil **lulus** | terisi | kosong | **clear** (keluar MCF) |
| **Signed failed** | Sudah tanda tangan, hasil **gagal** | terisi | kosong | `mcf` (perlu re-flight) |
| **Cancelled** | Draft di-set No ("tidak jadi", tak pernah sign) | kosong | terisi | **lepas** (fleet placeholder) |
**Aturan penting:**
- **1 MCF terbuka (draft) per helikopter.** `set active:true` saat draft sudah ada → **meng-update** draft itu, bukan bikin baru.
- **Cancelled hanya untuk draft** (yang belum pernah di-sign). MCF yang sudah signed = **event nyata**, tidak bisa di-cancel.
- MCF yang cancelled/signed **tetap tersimpan** di list (`get-by-helicopter`) sebagai **log/history**.
---
## 3. Flow lengkap (langkah + apa yang terjadi)
### Langkah 1 — Aktifkan / isi / edit draft
`POST /mcf/set/{helicopter_id}`
```json
{
"data": {
"type": "mcf_set",
"attributes": {
"active": true,
"flight_id": "019d6774-98e8-7fba-9ef4-3795c0da8a13",
"af_hours": "1245.3",
"landing_count": 3,
"date": "2026-06-17",
"result": "passed",
"notes": "MCF setelah pekerjaan engine mount"
}
}
}
```
**Apa yang terjadi:**
- Belum ada draft → **buat draft baru****201**.
- Sudah ada draft terbuka → **update draft itu** dengan data yang dikirim → **200**.
- Semua field data **opsional** — boleh isi sebagian dulu, lengkapi bertahap dengan `set` lagi.
- Helikopter masuk **status `mcf`**. Di fleet, object `mcf` muncul (`sign: false`, `af_hours`/`landing_count` pre-fill dari total pesawat kalau belum diisi).
> `active` boleh diomit → dianggap `true`.
### Langkah 2 — Finalize (tanda tangan)
`POST /mcf/sign/{id}`
```json
{
"data": {
"type": "mcf_sign",
"attributes": {
"sign": true,
"af_hours": "1245.3",
"landing_count": 3,
"utc_time": "2026-06-17T08:30:00Z",
"date": "2026-06-17",
"result": "passed"
}
}
}
```
**Apa yang terjadi:**
- **Wajib lengkap**: `af_hours`, `landing_count`, `utc_time`, `date`, `result` (else **422**, pointer ke field yang kurang).
- MCF jadi **signed/completed** (`completed_at` + `completed_by` terisi).
- `result: passed`**status MCF clear** (helikopter keluar dari MCF).
- `result: failed` → helikopter **tetap `mcf`** (perlu re-flight — buat MCF baru lewat `set active:true`).
### Langkah 3 — Batal tanda tangan (unsign)
`POST /mcf/sign/{id}` dengan `{ "sign": false }`
**Apa yang terjadi:**
- MCF yang signed **balik jadi draft** (`completed_at` dikosongkan) — **datanya tetap**.
- Bisa di-edit lagi lewat `set`, lalu di-`sign` ulang.
### Langkah 4 — Nonaktifkan / batal (set No)
`POST /mcf/set/{helicopter_id}` dengan `{ "active": false }`
**Apa yang terjadi:**
- Draft (belum sign) → ditandai **`cancelled`** ("tidak jadi") → **status MCF lepas** → fleet balik **placeholder**. Record **tetap di list** sebagai log cancelled.
- **Sudah signed → BUKAN batal** (event nyata). MCF dibiarkan; response mengembalikan MCF signed itu apa adanya. Di fleet **datanya tetap muncul** (lihat §5): untuk `passed` helikopter sudah **keluar status MCF** ("active no") tapi data MCF sebelumnya **tetap tampil**; untuk `failed` helikopter tetap `mcf`.
- Tidak ada MCF sama sekali → **no-op** (idempotent, sudah "No").
---
## 4. Contoh alur khas
```
set {active:true, af_hours, result, ...} → draft dibuat (201)
set {active:true, ...revisi...} → draft di-update (200)
sign {sign:true, ...lengkap...} → signed (passed=clear / failed=tetap)
── kalau salah ──
sign {sign:false} → balik draft (data tetap) → edit → sign lagi
── kalau tidak jadi ──
set {active:false} → cancelled (lepas status, tetap jadi log)
```
---
## 5. Efek ke Fleet Status
Object `mcf` di response fleet (`GET /api/v1/fleet-status/...`) selalu mencerminkan **MCF terbaru** helikopter. Field **`active`** = apakah helikopter **sedang dalam MCF** (draft, atau signed-failed yang perlu re-flight); `false` kalau cleared/cancelled/tidak ada.
| Kondisi MCF terbaru | `active` | Object `mcf` di fleet |
|---|---|---|
| Tidak ada / cancelled | `false` | **placeholder**`id: ""`, `sign: false`, `af_hours`/`landing_count` dari total pesawat |
| Draft (belum sign) | `true` | id draft, `sign: false`, data pre-fill dari pesawat kalau kosong |
| Signed passed | `false` | id, `sign: true`, snapshot MCF + `signed_by` (data tetap tampil walau status sudah clear) |
| Signed failed | `true` | id, `sign: true`, snapshot + `signed_by` (masih MCF, perlu re-flight) |
Urutan status di fleet & hold list: **MCF → AOG → Booked → Available** (paling kritikal dulu).
---
## 6. Ringkasan endpoint
| Method | Path | Fungsi |
|---|---|---|
| `POST` | `/api/v1/mcf/set/{helicopter_id}` | activate/edit draft (`active:true`) · cancel (`active:false`) |
| `POST` | `/api/v1/mcf/sign/{id}` | finalize (`sign:true`) · unsign (`sign:false`) |
| `GET` | `/api/v1/mcf/get/{id}` | baca satu MCF |
| `GET` | `/api/v1/mcf/get-by-helicopter/{helicopter_id}` | daftar MCF helikopter (log) |
**Permission:** `mcf.create` (untuk `set`), `mcf.complete` (untuk `sign`), `mcf.read` (untuk `get`).
---
## 7. Kode error umum
| Kode | Kapan |
|---|---|
| `MCF_INVALID_UUID` | `helicopter_id` / `id` bukan UUID valid (422) |
| `MCF_INVALID_JSON` | body JSON rusak (400) |
| `INVALID_DATA_TYPE` | tipe field salah (mis. `flight_id` dikirim angka) (422) |
| `MCF_INVALID_PAYLOAD` | field check-flight kurang/salah saat `sign:true` (422, pointer ke field) |
| `MCF_NOT_FOUND` | MCF id tidak ada (404) |
> **Catatan:** relasi MCF ke penerbangan lewat `flight_id` (opsional). Lihat juga [aog-mcf-nsr-flow.md](aog-mcf-nsr-flow.md) untuk gambaran status helikopter secara keseluruhan (AOG/MCF/NSR).

91
docs/password-reset.md Normal file
View File

@@ -0,0 +1,91 @@
# Password Reset
## Endpoints
### `POST /api/v1/auth/forgot-password`
Request:
```json
{
"data": {
"type": "auth_forgot_password",
"attributes": {
"email": "user@example.com"
}
}
}
```
Response (`200` always for valid payload):
```json
{
"data": {
"type": "auth_forgot_password",
"attributes": {
"message": "If the account exists, a password reset link has been sent."
}
}
}
```
### `POST /api/v1/auth/reset-password`
Request:
```json
{
"data": {
"type": "auth_reset_password",
"attributes": {
"token": "<raw-token>",
"new_password": "StrongPassphrase123!",
"confirm_password": "StrongPassphrase123!"
}
}
}
```
Success response (`200`):
```json
{
"data": {
"type": "auth_reset_password",
"attributes": {
"message": "Password has been reset successfully. Please log in again."
}
}
}
```
Invalid/expired/used token response (`400`):
```json
{
"errors": [
{
"status": "400",
"title": "Invalid reset link",
"detail": "This reset link is invalid or expired."
}
]
}
```
## Environment Variables
- `AUTH_PASSWORD_RESET_URL` frontend reset page URL; backend appends `?token=...`
- `AUTH_PASSWORD_RESET_TTL` reset token TTL (default `20m`, enforced 15-30m range)
- `AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN` cooldown per email identifier (default `2m`)
- `AUTH_FORGOT_PASSWORD_MIN_DURATION` minimum forgot-password response duration (default `150ms`)
- `AUTH_FORGOT_PASSWORD_IP_RATE_MAX` per-IP max attempts in window (default `10`)
- `AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW` per-IP window duration (default `15m`)
## Security Decisions
- Uses `crypto/rand` for reset token entropy.
- Stores only SHA-256 token hashes in DB (`password_reset_tokens.token_hash`).
- Raw token is only delivered via email link.
- Tokens are single-use (`used_at`) and expire (`expires_at`).
- New forgot-password request invalidates previous active reset tokens for that user.
- Successful reset invalidates all active reset tokens for the user.
- Successful reset revokes active sessions by incrementing the MySQL-backed user session version used in JWT claims.
- Forgot-password response is generic to prevent account enumeration.
- Forgot-password throttling is applied per IP and per email identifier.

157
docs/queue-sqs-migration.md Normal file
View File

@@ -0,0 +1,157 @@
# Queue Migration Notes
This project no longer supports Redis as a runtime dependency. Queue publishing and consumption are SQS-only, while transient auth/runtime state is stored in MySQL.
## Current Flow
1. API writes business data to MySQL.
2. If `QUEUE_OUTBOX_ENABLED=true`, email jobs are persisted to `email_outbox_messages`.
3. Worker polls the outbox and publishes serialized messages to SQS.
4. Worker consumes SQS messages with long polling and sends email through Amazon SES v2.
5. Successful processing deletes the SQS message.
6. Failed processing leaves the message unacked so SQS visibility timeout and DLQ redrive policy handle retries.
## What Changed
- Removed Redis producer, consumer, and Redis-backed idempotency paths.
- Removed Redis token store used for:
- refresh/session bookkeeping
- SSO state
- TOTP/WebAuthn challenges
- security PIN lock/cooldown state
- Added MySQL-backed transient stores:
- `auth_runtime_tokens`
- `queue_idempotency_records`
- Simplified bootstrap:
- API uses MySQL + optional direct SQS publisher when outbox is disabled.
- Worker uses MySQL + SQS only.
## Required Environment
Queue and worker settings:
```bash
EMAIL_PROVIDER=mock
EMAIL_MOCK_DIR=.local/emails
QUEUE_MESSAGE_SCHEMA_VERSION=v2
QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK=true
QUEUE_IDEMPOTENCY_KEY_PREFIX=queue:idempotency:email:
QUEUE_IDEMPOTENCY_TTL=24h
QUEUE_PROCESSING_TTL=15m
QUEUE_WORKER_CONCURRENCY=4
QUEUE_WORKER_MAX_BATCH_SIZE=10
QUEUE_WORKER_PROCESS_TIMEOUT=30s
QUEUE_WORKER_SHUTDOWN_TIMEOUT=30s
QUEUE_WORKER_BACKOFF_MIN=1s
QUEUE_WORKER_BACKOFF_MAX=5m
QUEUE_WORKER_PERMANENT_FAILURE_DELAY=30s
QUEUE_WORKER_MONITOR_ADDR=127.0.0.1:9090
QUEUE_WORKER_HEALTH_ERROR_THRESHOLD=3
QUEUE_WORKER_DEPTH_POLL_INTERVAL=30s
QUEUE_OUTBOX_ENABLED=true
QUEUE_OUTBOX_POLL_INTERVAL=1s
QUEUE_OUTBOX_BATCH_SIZE=10
QUEUE_OUTBOX_DISPATCH_TIMEOUT=10s
QUEUE_OUTBOX_LOCK_TTL=1m
QUEUE_OUTBOX_MAX_ATTEMPTS=20
```
SES settings:
```bash
AWS_REGION=ap-southeast-1
SES_REGION=
SES_FROM=no-reply@example.com
SES_CONFIGURATION_SET=
SES_SEND_TIMEOUT=10s
SES_ENDPOINT=
SES_ALLOW_INSECURE_ENDPOINT=false
```
Local/dev recommendation when you do not have AWS SES:
- set `EMAIL_PROVIDER=mock`
- keep SQS on LocalStack if needed
- inspect delivered mock emails in `EMAIL_MOCK_DIR`
AWS / SQS settings:
```bash
AWS_REGION=ap-southeast-1
SQS_ENDPOINT=
SQS_ALLOW_INSECURE_ENDPOINT=false
SQS_QUEUE_URL=https://sqs.ap-southeast-1.amazonaws.com/123456789012/wucher-email
SQS_QUEUE_TYPE=standard
SQS_MESSAGE_GROUP_ID=email
SQS_MAX_MESSAGES=10
SQS_WAIT_TIME_SECONDS=20
SQS_VISIBILITY_TIMEOUT=60s
```
## SQS Expectations
Configure the queue outside the application with:
- long polling enabled
- sensible visibility timeout
- DLQ redrive policy
- `maxReceiveCount` aligned with retry tolerance
Recommended baseline:
- `ReceiveMessageWaitTimeSeconds=20`
- `VisibilityTimeout=60`
- `maxReceiveCount=5`
## IAM
API needs `sqs:SendMessage` only when `QUEUE_OUTBOX_ENABLED=false`.
Worker needs:
- `sqs:SendMessage` to the main queue when dispatching outbox messages
- `sqs:ReceiveMessage`
- `sqs:DeleteMessageBatch`
- `sqs:ChangeMessageVisibility`
- `sqs:GetQueueAttributes`
- `ses:SendEmail`
If SES event tracking is enabled, provision it separately with:
- SES configuration set event destinations
- SNS topic publish permissions
- a dedicated SQS event queue and consumer policy
## Breaking Changes
- Remove these Redis env vars from deployment:
- `REDIS_ADDR`
- `REDIS_PASSWORD`
- `REDIS_DB`
- `QUEUE_PRIMARY_BACKEND`
- `QUEUE_SECONDARY_BACKEND`
- `QUEUE_SECONDARY_REQUIRED`
- `QUEUE_CONSUMER_BACKEND`
- `QUEUE_IDEMPOTENCY_BACKEND`
- `QUEUE_PRODUCER_MODE`
- `QUEUE_REDIS_DLQ_KEY`
- `AUTH_EMAIL_QUEUE_KEY`
- `SQS_DLQ_URL`
- Worker now requires MySQL even when it is only consuming SQS, because idempotency state is persisted in MySQL.
- Old Redis backlog is not consumed by this version. Drain or discard legacy Redis queues before deploy.
## Deployment Checklist
1. Ensure MySQL migration/AutoMigrate runs and creates `auth_runtime_tokens` and `queue_idempotency_records`.
2. Create or verify the SQS queue and DLQ redrive policy.
3. Verify the SES identity/domain and optional configuration set.
4. Remove Redis and SMTP config from runtime manifests and secrets.
5. Deploy API and worker.
6. Verify:
- worker `/health`
- worker `/metrics`
- SQS queue depth
- outbox rows are progressing from `pending` to `published`
- SES send acceptance/message IDs in worker logs
7. Manually delete Redis infrastructure after confirming there is no remaining backlog you still need.

349
docs/security-pin.md Normal file
View File

@@ -0,0 +1,349 @@
# Security PIN Flow
This document explains how to configure and use a per-user 6-digit Security PIN for sensitive actions.
All API payloads follow **JSON:API**.
## 1) Setup Security PIN (first time)
Endpoint:
`POST /api/v1/auth/pin/setup`
Auth:
- Requires logged-in session (`AUTH` cookie / access token cookie)
Request:
```json
{
"data": {
"type": "auth_pin_setup",
"attributes": {
"pin": "123456",
"confirm_pin": "123456"
}
}
}
```
Success (`200`):
```json
{
"data": {
"type": "auth_pin_setup",
"attributes": {
"configured": true
}
}
}
```
If PIN already exists (`409`):
```json
{
"errors": [
{
"status": "409",
"title": "PIN already configured",
"detail": "security PIN is already set"
}
]
}
```
## 2) Verify PIN before sensitive action
Endpoint:
`POST /api/v1/auth/pin/verify`
Auth:
- Requires logged-in session
Request:
```json
{
"data": {
"type": "auth_pin_verify",
"attributes": {
"pin": "123456",
"action": "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"
}
}
}
```
Success (`200`):
```json
{
"data": {
"type": "auth_pin_verify",
"attributes": {
"verified": true,
"action_token": "<one-time-token>",
"action_token_ttl_ms": 300000
}
}
}
```
Use the returned token on the sensitive API call:
- Header: `X-PIN-Verification-Token: <one-time-token>`
Example sensitive request:
```http
DELETE /api/v1/users/delete/{uuid}
X-PIN-Verification-Token: <one-time-token>
Cookie: wucher_at=...
```
Important:
- Token is action-bound (`a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74` only for that action).
- Token is one-time use.
- Token expires by `AUTH_SECURITY_PIN_ACTION_TOKEN_TTL`.
- Maximum failed attempts follow `AUTH_SECURITY_PIN_MAX_ATTEMPTS` (default `5`).
- When max attempts are reached, API returns `429` and PIN becomes blocked.
- Block is permanent until PIN reset succeeds (`AUTH_SECURITY_PIN_LOCK_DURATION=0` means no auto-unlock timeout).
## 3) Change Security PIN
Endpoint:
`POST /api/v1/auth/pin/change`
Auth:
- Requires logged-in session
Request:
```json
{
"data": {
"type": "auth_pin_change",
"attributes": {
"current_pin": "123456",
"new_pin": "654321",
"confirm_pin": "654321"
}
}
}
```
Success (`200`):
```json
{
"data": {
"type": "auth_pin_change",
"attributes": {
"updated": true
}
}
}
```
## 4) Reset Security PIN Flow
### 4.1 Request reset link (blocked PIN button)
Endpoint:
`POST /api/v1/auth/pin/request-reset`
Auth:
- Requires logged-in session
Request:
```json
{
"data": {
"type": "auth_pin_request_reset",
"attributes": {
"password": "<current-account-password>"
}
}
}
```
Success (`200`):
```json
{
"data": {
"type": "auth_pin_request_reset",
"attributes": {
"message": "If allowed, a security PIN reset link has been sent to your email."
}
}
}
```
If PIN not blocked (`409`):
```json
{
"errors": [
{
"status": "409",
"title": "PIN not blocked",
"detail": "security PIN is not blocked"
}
]
}
```
Invalid password (`401`):
```json
{
"errors": [
{
"status": "401",
"title": "Unauthorized",
"detail": "invalid password"
}
]
}
```
Rate limited (`429`):
```json
{
"errors": [
{
"status": "429",
"title": "Too many requests",
"detail": "please wait before requesting another reset link"
}
]
}
```
### 4.2 Request reset link (public forgot endpoint)
Endpoint:
`POST /api/v1/auth/pin/forgot`
Request:
```json
{
"data": {
"type": "auth_forgot_pin",
"attributes": {
"email": "user@example.com"
}
}
}
```
Response is always generic (`200`):
```json
{
"data": {
"type": "auth_forgot_pin",
"attributes": {
"message": "If the account exists, a security PIN reset link has been sent."
}
}
}
```
### 4.3 Reset PIN with password or Microsoft re-auth
Endpoint:
`POST /api/v1/auth/pin/reset`
Notes:
- `method` is optional. Default is `password`.
- For `method=password`, `token` is required and must be the raw single-use reset token from the email reset link, then send `password`.
- For `method=microsoft`, `token` is not required. Send `reauth_token` from Microsoft re-auth directly.
Request with password re-auth:
```json
{
"data": {
"type": "auth_reset_pin",
"attributes": {
"token": "<raw-token-from-email-link>",
"password": "<current-account-password>",
"new_pin": "123456",
"confirm_pin": "123456"
}
}
}
```
Request with Microsoft re-auth:
```json
{
"data": {
"type": "auth_reset_pin",
"attributes": {
"method": "microsoft",
"reauth_token": "<reauth-token-from-/auth/microsoft/reauth?purpose=pin_reset_request>",
"new_pin": "123456",
"confirm_pin": "123456"
}
}
}
```
Success (`200`):
```json
{
"data": {
"type": "auth_reset_pin",
"attributes": {
"message": "Security PIN has been reset successfully.",
"reset_success": true,
"redirect_url": "https://app.example.com"
}
}
}
```
Invalid/expired token (`400`):
```json
{
"errors": [
{
"status": "400",
"title": "Invalid reset link",
"detail": "This reset link is invalid or expired."
}
]
}
```
Invalid password (`401`):
```json
{
"errors": [
{
"status": "401",
"title": "Unauthorized",
"detail": "invalid password"
}
]
}
```
## 5) Sensitive endpoints currently protected by PIN token
The following routes require `X-PIN-Verification-Token`:
- `POST /api/v1/auth/invite` (`action=2a59e8b4-5a72-4be3-b8fc-4f49941181c3`)
- `POST /api/v1/users/create` (`action=2a59e8b4-5a72-4be3-b8fc-4f49941181c3`)
- `PATCH /api/v1/users/update/:uuid` (`action=428fdf48-3f99-46ea-bc47-8fb2e312be08`)
- `DELETE /api/v1/users/delete/:uuid` (`action=a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74`)
- `POST /api/v1/roles/create` (`action=7fb1d693-8692-4e8f-be86-fd0e29e96bc3`)
- `PATCH /api/v1/roles/update/:uuid` (`action=18ca5bbf-ce9d-4e24-8ace-793de56c1de0`)
- `DELETE /api/v1/roles/delete/:uuid` (`action=b87fca76-783d-45e7-9022-b2d9c95f6da7`)
- `POST /api/v1/roles/:uuid/permissions` (`action=48e523f8-d86f-4b24-b0e3-e213e4d33db4`)
- `DELETE /api/v1/roles/:uuid/permissions/:permission_uuid` (`action=f2bf59f8-0783-40fc-94ce-a79e40f84c44`)
- `POST /api/v1/roles/:uuid/permissions/assign-bulk` (`action=48e523f8-d86f-4b24-b0e3-e213e4d33db4`)
- `POST /api/v1/roles/:uuid/permissions/remove-bulk` (`action=f2bf59f8-0783-40fc-94ce-a79e40f84c44`)
## 6) Security controls
- PIN must be exactly 6 digits.
- PIN is encrypted at rest (`users.security_pin_encrypted`), not stored in plaintext.
- Forgot/reset tokens are random, hashed, single-use, and expiring.
- Verify attempts are rate-limited per user with lockout (`AUTH_SECURITY_PIN_MAX_ATTEMPTS`, `AUTH_SECURITY_PIN_LOCK_DURATION`).
- Forgot endpoint response is generic to reduce account enumeration risk.
## 7) Environment variables
- `AUTH_SECURITY_PIN_RESET_URL`
- `AUTH_SECURITY_PIN_RESET_TTL`
- `AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN`
- `AUTH_FORGOT_SECURITY_PIN_MIN_DURATION`
- `AUTH_SECURITY_PIN_MAX_ATTEMPTS`
- `AUTH_SECURITY_PIN_LOCK_DURATION`
- `AUTH_SECURITY_PIN_ACTION_TOKEN_TTL`

38365
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

26515
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

483
docs/takeover-file-api.md Normal file
View File

@@ -0,0 +1,483 @@
# Takeover File API
Dokumen ini menjelaskan alur lengkap file pada `takeover`:
1. upload intent file
2. finalize upload menjadi file manager file tanpa parent takeover
3. create takeover baru sambil menempelkan file pakai `file_id`
4. update takeover dengan mode add, remove, atau replace file
5. response takeover dan FM report yang menampilkan file
6. SSE yang tetap dipakai dari file manager
## Ringkasan Konsep
Ada 3 objek yang perlu dibedakan:
1. `file`
- file fisik di file manager
2. `attachment`
- relasi file ke reference bisnis, misalnya `takeover`
3. `takeover_file`
- relasi khusus takeover ke `file_attachment_id`
Flow yang dipakai di sistem ini:
1. FE minta upload intent ke file manager
2. FE upload binary file ke storage
3. FE finalize file lewat endpoint takeover file dan menerima `file_id`
4. FE kirim `file_id` saat create/update takeover
5. Backend membuat attachment `ref_type = takeover`
6. Backend menyimpan relasi takeover file
## Endpoint Yang Dipakai
### File Manager
#### Request upload intent untuk takeover
`POST /api/v1/file-manager/files/upload-takeover-file`
Fungsi:
1. minta upload intent
2. dapat `upload_intent_uuid`
3. dapat `upload_url`
#### Finalize file takeover
`POST /api/v1/file-manager/files/create-takeover-file`
Fungsi:
1. finalize file dari upload intent
2. membuat file manager file node
3. mengembalikan `file_id` untuk dipakai di takeover create/update
Endpoint ini tidak membuat `attachment` dan tidak membuat row `takeover_files`.
Catatan:
- `takeover_uuid` tidak wajib
- file boleh sementara tanpa parent takeover
- attachment takeover baru dibuat saat `POST /api/v1/takeovers/create` atau `PATCH /api/v1/takeovers/update/{id}`
### Takeover
#### Create takeover baru
`POST /api/v1/takeovers/create`
#### Update takeover
`PATCH /api/v1/takeovers/update/{id}`
## Flow Create Takeover Dengan File
### 1. FE minta upload intent file
Pakai endpoint:
`POST /api/v1/file-manager/files/upload-takeover-file`
Contoh payload:
```json
{
"data": [
{
"type": "file_manager_file_upload_intent",
"attributes": {
"name": "takeover-report.pdf",
"content_type": "application/pdf",
"size_bytes": 123456
}
}
]
}
```
Response akan mengembalikan:
1. `upload_intent_uuid`
2. `upload_url`
3. `method`
4. `required_headers`
### 2. FE upload binary ke storage
Gunakan `upload_url` dan `method` dari response upload intent.
### 3. FE finalize upload menjadi file manager file
Pakai endpoint:
`POST /api/v1/file-manager/files/create-takeover-file`
Contoh payload:
```json
{
"data": [
{
"type": "file_manager_file_create",
"attributes": {
"upload_intent_uuid": "019e8713-aa19-7fb7-a510-a0332dc69697",
"name": "takeover-report.pdf"
}
}
]
}
```
Response akan mengembalikan file manager resource. Ambil `data[0].resource.id` sebagai `file_id`.
Contoh potongan response:
```json
{
"data": [
{
"index": 0,
"success": true,
"resource": {
"type": "file_manager_file",
"id": "019e8714-1111-7aaa-8bbb-222222222222",
"attributes": {
"name": "takeover-report.pdf"
}
}
}
]
}
```
### 4. FE kirim takeover create
Pakai endpoint:
`POST /api/v1/takeovers/create`
Field file yang didukung:
`data.attributes.files[]`
Struktur item:
```json
{
"file_id": "019e8714-1111-7aaa-8bbb-222222222222"
}
```
Contoh payload:
```json
{
"data": {
"type": "takeover_create",
"attributes": {
"base_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"duty_date": "2026-06-22",
"notes": "Takeover baru dengan file",
"files": [
{
"file_id": "019e8714-1111-7aaa-8bbb-222222222222"
}
]
}
}
}
```
### 5. Backend attach file ke takeover
Saat request create takeover diproses, backend akan:
1. membuat takeover
2. membaca `file_id` dari `data.attributes.files[]`
3. create attachment dengan `ref_type = takeover`
4. menyimpan relasi ke `takeover_files`
Catatan kompatibilitas:
- item lama dengan `upload_intent_uuid` masih didukung sebagai fallback
- flow baru yang disarankan adalah kirim `file_id`
## Flow Update Takeover Dengan File
Pakai endpoint:
`PATCH /api/v1/takeovers/update/{id}`
Ada 3 mode yang disarankan.
### Mode 1. Add file baru
Gunakan:
- `files`
- atau `files_add`
Keduanya menerima item yang sama:
```json
{
"file_id": "019e8714-3333-7aaa-8bbb-444444444444"
}
```
Contoh:
```json
{
"data": {
"type": "takeover_update",
"id": "019eed5d-36c2-72d9-acab-388b4e41a949",
"attributes": {
"notes": "Tambah file baru",
"files_add": [
{
"file_id": "019e8714-3333-7aaa-8bbb-444444444444"
}
]
}
}
}
```
### Mode 2. Remove file tertentu
Gunakan:
`files_remove`
Isi field ini adalah `file_attachment_id`, bukan `takeover_file.id`.
Contoh:
```json
{
"data": {
"type": "takeover_update",
"id": "019eed5d-36c2-72d9-acab-388b4e41a949",
"attributes": {
"files_remove": [
"019d7000-aaaa-7bbb-8ccc-222222222222"
]
}
}
}
```
Saat remove:
1. relasi `takeover_files` dihapus
2. attachment file-manager juga dihapus
3. file lifecycle ikut direconcile oleh file manager
### Mode 3. Replace semua file
Gunakan:
`files_replace`
Mode ini:
1. menghapus semua file lama dari takeover
2. menambahkan daftar file baru
Aturan:
1. `files_replace` tidak boleh digabung dengan `files`
2. `files_replace` tidak boleh digabung dengan `files_add`
3. `files_replace` tidak boleh digabung dengan `files_remove`
Contoh:
```json
{
"data": {
"type": "takeover_update",
"id": "019eed5d-36c2-72d9-acab-388b4e41a949",
"attributes": {
"files_replace": [
{
"file_id": "019e8714-5555-7aaa-8bbb-666666666666"
}
]
}
}
}
```
### Mode campuran: add + remove
Kalau kamu ingin menambah file baru sambil menghapus file lama, pakai:
1. `files_add`
2. `files_remove`
Contoh:
```json
{
"data": {
"type": "takeover_update",
"id": "019eed5d-36c2-72d9-acab-388b4e41a949",
"attributes": {
"files_add": [
{
"file_id": "019e8714-7777-7aaa-8bbb-888888888888"
}
],
"files_remove": [
"019d7000-aaaa-7bbb-8ccc-222222222222"
]
}
}
}
```
## Validation Rules
### Create takeover
Field yang wajib:
1. `duty_date`
Field file:
1. `files[]` opsional
2. untuk flow baru, setiap item file berisi:
- `file_id`
Catatan:
- `upload_intent_uuid` + `name` masih bisa dipakai sebagai fallback lama
- untuk flow baru, gunakan `file_id` dari response `/create-takeover-file`
### Update takeover
Field file yang tersedia:
1. `files`
2. `files_add`
3. `files_remove`
4. `files_replace`
Aturan kombinasi:
1. `files_replace` eksklusif
2. `files` dan `files_add` diperlakukan sebagai add file baru
3. `files_remove` harus berisi UUID attachment yang masih terhubung ke takeover
## Response Takeover
Response takeover sekarang punya field:
`data.attributes.files`
Contoh item:
```json
{
"type": "takeover_file",
"id": "019d7000-aaaa-7bbb-8ccc-111111111111",
"attributes": {
"file_attachment_id": "019d7000-aaaa-7bbb-8ccc-222222222222",
"file_name": "takeover-report.pdf",
"download_url": "https://..."
}
}
```
Arti field:
1. `id`
- UUID relasi `takeover_file`
2. `file_attachment_id`
- UUID attachment file-manager
3. `file_name`
- nama file yang tersimpan
4. `download_url`
- URL download file jika storage tersedia
## Response FM Report
Response `fm report` juga menampilkan file takeover melalui:
`data.attributes.takeover_files`
Isi resource sama seperti response takeover:
1. `id`
2. `file_attachment_id`
3. `file_name`
4. `download_url`
## SSE
Flow takeover file tetap memakai SSE file manager yang sudah ada.
Event utama:
`file.updated`
Artinya saat file berhasil difinalisasi atau status file berubah, FE tetap bisa mendengarkan event realtime dari stream file manager.
Endpoint stream:
`GET /api/v1/file-manager/events`
Catatan:
1. belum ada stream SSE takeover-file khusus
2. yang dipakai adalah stream file manager yang sudah eksisting
## Migration dan Struktur Database
Tabel baru:
`takeover_files`
Kolom:
1. `id`
2. `takeover_id`
3. `file_attachment_id`
Migration:
`migrations/20260622_create_takeover_files.sql`
## Best Practice Pemakaian
1. Finalize upload dulu lewat `/api/v1/file-manager/files/create-takeover-file`
2. Kalau takeover sudah ada, gunakan `PATCH /api/v1/takeovers/update/{id}`
3. Pakai `files_replace` hanya kalau memang ingin mengganti semua file
4. Pakai `files_add` dan `files_remove` kalau ingin perubahan parsial yang lebih aman
5. Untuk menghapus file, gunakan `file_attachment_id` dari response takeover
## Contoh Alur Yang Disarankan
### Create takeover baru dengan 1 file
1. request upload intent
2. upload binary ke storage
3. panggil `POST /api/v1/file-manager/files/create-takeover-file`
4. ambil `file_id` dari response
5. panggil `POST /api/v1/takeovers/create` dengan `files[].file_id`
### Update takeover dan tambah file baru
1. request upload intent
2. upload binary ke storage
3. panggil `POST /api/v1/file-manager/files/create-takeover-file`
4. ambil `file_id` dari response
5. panggil `PATCH /api/v1/takeovers/update/{id}` dengan `files_add[].file_id`
### Update takeover dan replace semua file
1. request upload intent baru untuk file pengganti
2. upload binary ke storage
3. panggil `POST /api/v1/file-manager/files/create-takeover-file`
4. ambil `file_id` dari response
5. panggil `PATCH /api/v1/takeovers/update/{id}` dengan `files_replace[].file_id`

View File

@@ -0,0 +1,578 @@
# Takeover Flow Step by Step
Dokumen ini menjelaskan flow `takeover` dari sisi frontend secara detail: endpoint yang dipakai, urutan request, payload yang wajib dan opsional, serta contoh request/response.
Untuk detail khusus file takeover, lihat [docs/takeover-file-api.md](./takeover-file-api.md).
## Ringkasan Singkat
`Takeover` adalah endpoint atomic untuk membuat satu paket data sekaligus:
1. `takeover_ac`
2. `duty_roster`
3. `reserve_ac`
4. `flight_inspection`
5. `before_flight_inspection`
6. `flight_prep_check`
7. `flight`
Artinya frontend cukup kirim **1 request create takeover**, backend akan membuat seluruh record terkait dalam satu transaksi.
## Endpoint Yang Tersedia
Ada 1 route create takeover yang menuju handler yang sama:
1. `POST /api/v1/takeovers/create`
Keduanya memakai permission:
`reserve_ac.create`
Route lain yang tersedia:
1. `GET /api/v1/takeovers/get-all`
2. `GET /api/v1/takeovers/get/:id`
3. `PATCH /api/v1/takeovers/update/:id`
4. `DELETE /api/v1/takeovers/delete/:id`
Untuk dokumentasi ini fokus utama ada pada flow `create takeover`.
## Step by Step Flow
### Step 1. Siapkan data master terlebih dahulu
Sebelum hit create takeover, frontend harus sudah punya:
1. `base_id`
2. `helicopter_id`
3. `duty_date`
4. daftar crew untuk roster
5. data inspeksi sebelum terbang
6. data persiapan flight
Kalau salah satu ID tidak valid atau tidak ditemukan, backend akan gagal.
### Step 2. Susun payload utama
Payload mengikuti format JSON:API sederhana:
```json
{
"data": {
"type": "takeover",
"attributes": {
...
}
}
}
```
Field `type` menerima:
1. `takeover`
2. `takeover_create`
Frontend boleh kirim salah satu, tapi disarankan konsisten pakai `takeover_create` atau `takeover` sesuai standar proyek yang dipakai tim.
### Step 3. Kirim request create
Request create akan:
1. validasi JSON
2. validasi struktur payload
3. validasi UUID `base_id`
4. validasi UUID `helicopter_id`
5. validasi tanggal `duty_date`
6. membangun roster crew
7. membuat seluruh data dalam 1 transaksi
8. mengembalikan response takeover lengkap
Kalau satu sub-step gagal, seluruh transaksi gagal.
### Step 4. Terima response
Response sukses akan mengembalikan struktur `takeover` lengkap berisi:
1. `takeover_ac_id`
2. `duty_roster_id`
3. `flight_id`
4. `reserve_ac_id`
5. `flight_inspection_id`
6. `shift_start`
7. `shift_end`
8. `base`
9. `helicopter`
10. `roster_detail`
11. `inspection`
12. `flight`
13. `duty_roster`
Frontend sebaiknya memakai response ini sebagai sumber data final, bukan mengasumsikan data lokal sama persis dengan hasil backend.
## Request Detail
### Endpoint
`POST /api/v1/takeovers/create`
atau
`POST /api/v1/reserve-acs/takeovers/create`
### Headers
Gunakan header umum API proyek:
```http
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json
```
### Body
```json
{
"data": {
"type": "takeover",
"attributes": {
"base_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"duty_date": "2026-04-15",
"notes": "Opsional catatan takeover",
"shift_time": {
"shift_start": "06:00",
"shift_end": "18:00"
},
"roster_detail": {
"pilot": [
{
"id": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d",
"crew_type": "main",
"flight_instructor": true,
"line_checker": false,
"supervisor": false,
"examiner": false,
"co_pilot": true,
"shift_date": {
"date_start": "2026-04-15",
"date_end": "2026-04-15"
},
"shift_time": {
"shift_start": "06:00",
"shift_end": "14:00"
}
}
],
"doctor": [
{
"id": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
}
],
"rescuer": [],
"helper": [],
"other_person": [
{
"name": "Guest Person",
"mobile_phone": "+43-1234",
"email": "guest@example.com"
}
]
},
"helicopter_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"inspection": {
"inspection_date": "2026-04-15",
"before": {
"oil_engine_nr1_checked": true,
"oil_engine_nr2_checked": false,
"hydraulic_lh_checked": true,
"hydraulic_rh_checked": true,
"oil_transmission_mgb_checked": true,
"oil_transmission_igb_checked": true,
"oil_transmission_tgb_checked": true,
"fuel_amount": 50.5,
"fuel_unit": "LT",
"fuel_added_amount": 10,
"note": "Opsional note inspeksi sebelum terbang",
"file_checklist": [
{
"helicopter_file_id": "019d7000-aaaa-7bbb-8ccc-111111111111",
"is_done": true
}
]
},
"prepare": {
"notam_briefing": true,
"weather_briefing": true,
"operational_flight_plan": false,
"file_checklist": [
{
"helicopter_file_id": "019d7000-bbbb-7ccc-8ddd-222222222222",
"is_done": true
}
]
}
}
}
}
}
```
## Penjelasan Field
### `data`
Container utama request.
### `data.type`
Nilai yang diterima:
1. `takeover`
2. `takeover_create`
### `data.attributes.base_id`
Wajib. UUID base yang dipakai untuk takeover.
Backend akan memvalidasi:
1. format UUID
2. base harus ada di database
### `data.attributes.duty_date`
Wajib. Format tanggal harus `YYYY-MM-DD`.
Contoh:
`2026-04-15`
### `data.attributes.notes`
Opsional.
Gunakan untuk catatan tambahan takeover. Jika tidak ada, field ini boleh tidak dikirim atau dikirim `null`.
### `data.attributes.shift_time`
Secara schema tidak diberi `validate:"required"`, tetapi secara proses bisnis ini dianggap bagian penting dari takeover.
Format:
```json
{
"shift_start": "06:00",
"shift_end": "18:00"
}
```
Keterangan:
1. `shift_start` adalah waktu mulai tugas.
2. `shift_end` adalah waktu selesai tugas.
Jika frontend ingin aman, selalu kirim kedua field ini.
### `data.attributes.roster_detail`
Wajib secara bisnis. Berisi daftar crew yang akan masuk ke `duty_roster`.
Strukturnya terdiri dari:
1. `pilot`
2. `doctor`
3. `rescuer`
4. `helper`
5. `other_person`
### `data.attributes.helicopter_id`
Wajib. UUID helicopter yang dipakai pada takeover.
Backend akan memvalidasi:
1. format UUID
2. helicopter harus ada di database
### `data.attributes.inspection`
Wajib. Ini adalah paket inspeksi takeover.
#### `inspection.inspection_date`
Wajib. Format `YYYY-MM-DD`.
#### `inspection.before`
Wajib sebagai objek, tetapi isi field di dalamnya sebagian besar opsional.
Field opsional:
1. `oil_engine_nr1_checked`
2. `oil_engine_nr2_checked`
3. `hydraulic_lh_checked`
4. `hydraulic_rh_checked`
5. `oil_transmission_mgb_checked`
6. `oil_transmission_igb_checked`
7. `oil_transmission_tgb_checked`
8. `fuel_amount`
9. `fuel_unit`
10. `fuel_added_amount`
11. `note`
12. `file_checklist`
#### `inspection.before.fuel_unit`
Jika dikirim, nilai yang diterima backend adalah:
1. `LT`
2. `KG`
3. `POUND`
Kalau nilainya tidak valid, request akan gagal.
#### `inspection.before.file_checklist`
Array opsional.
Setiap item harus punya:
1. `helicopter_file_id`
2. `is_done`
#### `inspection.prepare`
Wajib sebagai objek, tetapi field di dalamnya opsional.
Field opsional:
1. `notam_briefing`
2. `weather_briefing`
3. `operational_flight_plan`
4. `file_checklist`
#### `inspection.prepare.file_checklist`
Array opsional.
Setiap item harus punya:
1. `helicopter_file_id`
2. `is_done`
## Detail Roster
### `roster_detail.pilot`
Array pilot. Setiap item dapat berisi:
1. `id` - UUID crew
2. `crew_type` - biasanya `main` atau `additional`
3. `flight_instructor` - boolean
4. `line_checker` - boolean
5. `supervisor` - boolean
6. `examiner` - boolean
7. `co_pilot` - boolean
8. `shift_date` - opsional
9. `shift_time` - opsional
### `roster_detail.doctor`
Array crew doctor.
Setiap item:
1. `id` - UUID crew
2. `shift_date` - opsional
3. `shift_time` - opsional
### `roster_detail.rescuer`
Array crew rescuer.
Setiap item:
1. `id` - UUID crew
2. `shift_date` - opsional
3. `shift_time` - opsional
### `roster_detail.helper`
Array crew helper.
Setiap item:
1. `id` - UUID crew
2. `shift_date` - opsional
3. `shift_time` - opsional
### `roster_detail.other_person`
Array orang tambahan yang bukan crew terdaftar.
Setiap item:
1. `name`
2. `mobile_phone`
3. `email`
## Field Optional Yang Perlu Diperhatikan Frontend
Ini yang paling penting supaya frontend tidak bingung:
### Optional level 1
1. `notes`
### Optional level 2 di `inspection.before`
1. Semua checkbox `*_checked`
2. `fuel_amount`
3. `fuel_unit`
4. `fuel_added_amount`
5. `note`
6. `file_checklist`
### Optional level 2 di `inspection.prepare`
1. `notam_briefing`
2. `weather_briefing`
3. `operational_flight_plan`
4. `file_checklist`
### Optional level 2 di roster detail
1. `shift_date`
2. `shift_time`
### Field yang tetap harus dianggap wajib oleh frontend
Walaupun beberapa field di struct tidak pakai `validate:"required"`, frontend sebaiknya tetap menyiapkan:
1. `base_id`
2. `duty_date`
3. `roster_detail`
4. `helicopter_id`
5. `inspection`
6. `inspection.inspection_date`
Alasannya:
1. backend memakai data itu untuk membangun transaksi
2. frontend akan lebih stabil jika payload selalu lengkap
3. respons error dari backend akan lebih mudah dihindari
## Contoh Request Minimal Yang Aman
Kalau ingin payload ringkas tetapi masih aman, gunakan pola berikut:
```json
{
"data": {
"type": "takeover",
"attributes": {
"base_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"duty_date": "2026-04-15",
"shift_time": {
"shift_start": "06:00",
"shift_end": "18:00"
},
"roster_detail": {
"pilot": [],
"doctor": [],
"rescuer": [],
"helper": [],
"other_person": []
},
"helicopter_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"inspection": {
"inspection_date": "2026-04-15",
"before": {},
"prepare": {}
}
}
}
}
```
Catatan:
1. Ini minimal secara struktur.
2. Secara bisnis, frontend biasanya tetap perlu mengisi roster dan checklist sesuai kebutuhan operasional.
## Response Sukses
HTTP status:
`201 Created`
Contoh bentuk response:
```json
{
"data": {
"type": "takeover",
"attributes": {
"takeover_ac_id": "019d8000-aaaa-7bbb-8ccc-333333333333",
"duty_roster_id": "019d8000-bbbb-7ccc-8ddd-444444444444",
"flight_id": "019d8000-cccc-7ddd-8eee-555555555555",
"reserve_ac_id": "019d8000-dddd-7eee-8fff-666666666666",
"flight_inspection_id": "019d8000-eeee-7fff-9000-777777777777",
"shift_start": "06:00",
"shift_end": "18:00",
"base_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"duty_date": "2026-04-15",
"helicopter_id": "019d6771-5fb5-7337-837f-bc5ed85181a1",
"notes": "Opsional catatan takeover",
"roster_detail": {
"pilot": [],
"doctor": [],
"rescuer": [],
"helper": [],
"other_person": []
},
"inspection": {},
"flight": {},
"duty_roster": {}
}
}
}
```
## Error Yang Umum
### 400 Bad Request
Biasanya terjadi karena:
1. JSON tidak valid
2. create gagal di service
3. UUID tidak bisa diproses
4. data referensi tidak ditemukan
### 422 Unprocessable Entity
Biasanya terjadi karena:
1. `type` salah
2. field wajib tidak ada
3. format UUID salah
4. tanggal bukan format `YYYY-MM-DD`
5. `fuel_unit` tidak valid
6. roster detail tidak valid
## Saran Implementasi Frontend
1. Selalu kirim payload lengkap dari form state.
2. Jangan kirim field boolean sebagai string.
3. Pastikan UUID sudah tervalidasi sebelum submit.
4. Validasi tanggal di frontend dengan format `YYYY-MM-DD`.
5. Jika user tidak isi field opsional, lebih baik field itu diomit daripada dikirim string kosong.
6. Setelah request sukses, gunakan response backend untuk refresh state halaman.
## Catatan Penting
1. `takeover` ini bersifat atomic.
2. Jika satu bagian gagal, semua data batal dibuat.
3. `notes` adalah satu-satunya field tingkat atas yang jelas opsional.
4. Banyak field di nested object yang secara schema terlihat opsional, tetapi secara flow bisnis tetap perlu diisi agar hasilnya masuk akal.

104
go.mod Normal file
View File

@@ -0,0 +1,104 @@
module wucher
go 1.26.0
toolchain go1.26.2
require (
github.com/aws/aws-sdk-go-v2 v1.41.4
github.com/aws/aws-sdk-go-v2/config v1.32.12
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.60.1
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24
github.com/aws/smithy-go v1.24.2
github.com/go-playground/validator/v10 v10.20.0
github.com/go-sql-driver/mysql v1.8.1
github.com/go-webauthn/webauthn v0.13.4
github.com/gofiber/fiber/v2 v2.52.12
github.com/gofiber/swagger v1.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/mattn/go-sqlite3 v1.14.22
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.21.1
github.com/prometheus/client_model v0.6.1
github.com/sony/gobreaker/v2 v2.4.0
github.com/swaggo/swag v1.16.3
github.com/valyala/fasthttp v1.51.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.39.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/beevik/etree v1.5.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/crewjam/saml v0.5.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.23 // indirect
github.com/gofiber/adaptor/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russellhaering/goxmldsig v1.4.0 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

249
go.sum Normal file
View File

@@ -0,0 +1,249 @@
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 h1:tB4tNw83KcajNAzaIMhkhVI2Nt8fAZd5A5ro113FEMY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7/go.mod h1:lvpyBGkZ3tZ9iSsUIcC2EWp+0ywa7aK3BLT+FwZi+mQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 h1:Hi0KGbrnr57bEHWM0bJ1QcBzxLrL/k2DHvGYhb8+W1w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7/go.mod h1:wKNgWgExdjjrm4qvfbTorkvocEstaoDl4WCvGfeCy9c=
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0 h1:SAfh4pNx5LuTafKKWR02Y+hL3A+3TX8cTKG1OIAJaBk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0/go.mod h1:r+xl5yzMk9083rMR+sJ5TYj9Tihvf/l1oxzZXDgGj2Q=
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.60.1 h1:vcU2PQ6/w4/somGiLRh21oUR3XOo7siz7lfkVtCiRQs=
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.60.1/go.mod h1:nLnJ93FXJeFHnJZEbm473c1BFHkg/kuCk7PDpv7OnnI=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24 h1:JP2wjWGmUp8lTCZb13Dv0Eciyc1jbO8pd0HZVMHFlrc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24/go.mod h1:Ql9ziDutk8ERAN9HMaYANCW3lop451ppebkxEJMLCTM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI=
github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4=
github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ=
github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -0,0 +1,219 @@
package api
import (
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
)
func AuditMiddleware(auditSvc *service.AuditLogService) fiber.Handler {
return func(c *fiber.Ctx) error {
if auditSvc == nil {
return c.Next()
}
start := time.Now()
err := c.Next()
status := c.Response().StatusCode()
if status == 0 {
status = fiber.StatusOK
}
requestID := c.GetRespHeader(fiber.HeaderXRequestID)
if requestID == "" {
requestID = localString(c.Locals("requestid"))
}
meta := map[string]any{
"latency_ms": time.Since(start).Milliseconds(),
}
if err != nil {
meta["error"] = err.Error()
}
module, action := resolveAuditModuleAction(c)
if !shouldPersistAuditByMethod(c.Method()) {
return err
}
auditSvc.Log(c.UserContext(), service.AuditLogInput{
RequestID: requestID,
ActorUserID: localUserID(c.Locals("user_id")),
Layer: "api",
Module: module,
Action: action,
Method: c.Method(),
Path: c.Path(),
StatusCode: status,
Success: status >= 200 && status < 400 && err == nil,
IP: c.IP(),
UserAgent: c.Get(fiber.HeaderUserAgent),
Message: "HTTP " + strconv.Itoa(status),
Metadata: meta,
})
return err
}
}
func shouldPersistAuditByMethod(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case fiber.MethodPost, fiber.MethodPut, fiber.MethodPatch, fiber.MethodDelete:
return true
default:
return false
}
}
func resolveAuditModuleAction(c *fiber.Ctx) (string, string) {
method := c.Method()
path := c.Path()
normalized := strings.ToLower(strings.TrimSpace(path))
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 3 || parts[0] != "api" || parts[1] != "v1" {
return sharedconst.AuditModuleUnknown, actionFromMethod(method)
}
moduleSegment := strings.TrimSpace(parts[2])
module := moduleFromPathSegment(moduleSegment)
if moduleSegment == "bases" {
module = resolveBaseModuleVariant(c, module)
}
suffix := ""
if len(parts) > 3 {
suffix = strings.TrimSpace(parts[len(parts)-1])
}
switch suffix {
case "get-all", "dt":
return module, sharedconst.AuditActionList
case "get":
return module, sharedconst.AuditActionGet
case "create":
return module, sharedconst.AuditActionCreate
case "update":
return module, sharedconst.AuditActionUpdate
case "delete":
return module, sharedconst.AuditActionDelete
case "export":
return module, sharedconst.AuditActionExport
}
return module, actionFromMethod(method)
}
func resolveBaseModuleVariant(c *fiber.Ctx, fallback string) string {
raw := strings.TrimSpace(c.Query("category_type"))
if raw == "" {
raw = strings.TrimSpace(c.Query("base_category"))
}
if raw == "" {
raw = strings.TrimSpace(c.Query("category"))
}
switch strings.ToLower(raw) {
case "regular":
return sharedconst.AuditModuleBaseRegular
case "hems":
return sharedconst.AuditModuleBaseHEMS
default:
return fallback
}
}
func actionFromMethod(method string) string {
switch strings.ToUpper(strings.TrimSpace(method)) {
case fiber.MethodGet:
return sharedconst.AuditActionGet
case fiber.MethodPost:
return sharedconst.AuditActionCreate
case fiber.MethodPut, fiber.MethodPatch:
return sharedconst.AuditActionUpdate
case fiber.MethodDelete:
return sharedconst.AuditActionDelete
default:
return sharedconst.AuditActionCustom
}
}
func moduleFromPathSegment(segment string) string {
switch strings.ToLower(strings.TrimSpace(segment)) {
case "air-rescuer-checklist":
return sharedconst.AuditModuleAirRescuerChecklist
case "audit-logs":
return sharedconst.AuditModuleAuditLog
case "auth":
return sharedconst.AuditModuleAuth
case "bases":
return sharedconst.AuditModuleBase
case "branding", "public":
return sharedconst.AuditModuleBranding
case "contacts":
return sharedconst.AuditModuleContact
case "duty-roster":
return sharedconst.AuditModuleDutyRoster
case "dul":
return sharedconst.AuditModuleDUL
case "facilities":
return sharedconst.AuditModuleFacility
case "federal-state":
return sharedconst.AuditModuleFederalState
case "file-manager":
return sharedconst.AuditModuleFileManager
case "flight-data":
return sharedconst.AuditModuleFlightData
case "flights":
return sharedconst.AuditModuleFlight
case "forces-present":
return sharedconst.AuditModuleForcesPresent
case "health-insurance-companies":
return sharedconst.AuditModuleHealthInsuranceCompany
case "helicopter-files":
return sharedconst.AuditModuleHelicopterFile
case "helicopters":
return sharedconst.AuditModuleHelicopter
case "hems-operation", "hems-operation-category", "hems-operational-data":
return sharedconst.AuditModuleUnknown
case "hospital":
return sharedconst.AuditModuleHospital
case "icao":
return sharedconst.AuditModuleICAO
case "insurance-patient-data":
return sharedconst.AuditModuleInsurancePatientData
case "land":
return sharedconst.AuditModuleLand
case "master-settings":
return sharedconst.AuditModuleMasterSetting
case "medicine":
return sharedconst.AuditModuleMedicine
case "mission":
return sharedconst.AuditModuleMission
case "opc":
return sharedconst.AuditModuleOPC
case "patient-data":
return sharedconst.AuditModulePatientData
case "reserve-acs":
return sharedconst.AuditModuleReserveAC
case "roles":
return sharedconst.AuditModuleRole
case "users":
return sharedconst.AuditModuleUser
case "vocation":
return sharedconst.AuditModuleVocation
default:
return sharedconst.AuditModuleUnknown
}
}
func localString(v any) string {
s, _ := v.(string)
return s
}
func localUserID(v any) []byte {
id, _ := v.([]byte)
if len(id) != 16 {
return nil
}
return id
}

View File

@@ -0,0 +1,208 @@
package api
import (
"context"
"net/http"
"sync"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/audit"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
)
type mockAuditMiddlewareRepo struct {
mu sync.Mutex
entries []*audit.AuditLog
}
func (m *mockAuditMiddlewareRepo) Create(_ context.Context, entry *audit.AuditLog) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *entry
m.entries = append(m.entries, &cp)
return nil
}
func TestAuditMiddleware_SkipsGETRequests(t *testing.T) {
repo := &mockAuditMiddlewareRepo{}
auditSvc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 16, Workers: 1})
app := fiber.New()
app.Use(AuditMiddleware(auditSvc))
app.Get("/api/v1/ping", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
req, _ := http.NewRequest(http.MethodGet, "/api/v1/ping", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) != 0 {
t.Fatalf("expected no audit log entry for GET, got=%d", len(repo.entries))
}
}
func TestAuditMiddleware_LogsMutationRequests(t *testing.T) {
repo := &mockAuditMiddlewareRepo{}
auditSvc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 16, Workers: 1})
app := fiber.New()
app.Use(AuditMiddleware(auditSvc))
app.Post("/api/v1/helicopters/create", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) == 0 {
t.Fatalf("expected audit log entry for mutation request")
}
got := repo.entries[0]
if got.Layer != "api" || got.Module != sharedconst.AuditModuleHelicopter || got.Action != sharedconst.AuditActionCreate {
t.Fatalf("unexpected audit entry: layer=%s module=%s action=%s", got.Layer, got.Module, got.Action)
}
if got.StatusCode != http.StatusCreated {
t.Fatalf("expected status 201, got %d", got.StatusCode)
}
}
func TestModuleFromPathSegment(t *testing.T) {
cases := []struct {
segment string
want string
}{
{segment: "helicopters", want: sharedconst.AuditModuleHelicopter},
{segment: "bases", want: sharedconst.AuditModuleBase},
{segment: "health-insurance-companies", want: sharedconst.AuditModuleHealthInsuranceCompany},
{segment: "hems-roster", want: sharedconst.AuditModuleUnknown},
{segment: "dul", want: sharedconst.AuditModuleDUL},
{segment: "reserve-acs", want: sharedconst.AuditModuleReserveAC},
{segment: "not-registered", want: sharedconst.AuditModuleUnknown},
}
for _, tc := range cases {
t.Run(tc.segment, func(t *testing.T) {
got := moduleFromPathSegment(tc.segment)
if got != tc.want {
t.Fatalf("moduleFromPathSegment(%q) = %q, want %q", tc.segment, got, tc.want)
}
})
}
}
func TestResolveAuditModuleAction(t *testing.T) {
cases := []struct {
name string
method string
target string
wantModule string
wantAction string
}{
{
name: "datatable list",
method: http.MethodGet,
target: "/api/v1/helicopters/get-all/dt",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionList,
},
{
name: "explicit create",
method: http.MethodPost,
target: "/api/v1/helicopters/create",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionCreate,
},
{
name: "fallback method update",
method: http.MethodPatch,
target: "/api/v1/helicopters/019dbeb6-4c37-7495-b0d0-e02ed134f87d",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionUpdate,
},
{
name: "base regular variant from query",
method: http.MethodGet,
target: "/api/v1/bases/get-all?category_type=regular",
wantModule: sharedconst.AuditModuleBaseRegular,
wantAction: sharedconst.AuditActionList,
},
{
name: "base hems variant from query alias",
method: http.MethodGet,
target: "/api/v1/bases/get-all?base_category=hems",
wantModule: sharedconst.AuditModuleBaseHEMS,
wantAction: sharedconst.AuditActionList,
},
{
name: "unknown module",
method: http.MethodGet,
target: "/api/v1/ping",
wantModule: sharedconst.AuditModuleUnknown,
wantAction: sharedconst.AuditActionGet,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
app := fiber.New()
app.All("/*", func(c *fiber.Ctx) error {
gotModule, gotAction := resolveAuditModuleAction(c)
if gotModule != tc.wantModule || gotAction != tc.wantAction {
t.Fatalf("resolveAuditModuleAction(%q, %q) = (%q, %q), want (%q, %q)",
tc.method, tc.target, gotModule, gotAction, tc.wantModule, tc.wantAction)
}
return c.SendStatus(http.StatusNoContent)
})
req, _ := http.NewRequest(tc.method, tc.target, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
})
}
}

View File

@@ -0,0 +1,131 @@
package api
import (
"archive/zip"
"bytes"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
)
// buildTinyXLSX returns a minimal but structurally valid .xlsx (ZIP) payload.
func buildTinyXLSX(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
entries := map[string]string{
"[Content_Types].xml": `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>`,
"_rels/.rels": `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`,
}
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, content); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func assertValidZip(t *testing.T, b []byte) {
t.Helper()
if _, err := zip.NewReader(bytes.NewReader(b), int64(len(b))); err != nil {
t.Fatalf("payload is not a valid zip: %v", err)
}
}
func TestIsWOPIRequest(t *testing.T) {
app := fiber.New()
cases := []struct {
path string
want bool
}{
{"/wopi/files/123/contents", true},
{"/wopi/files/123", true},
{"/api/file-manager/files", false},
{"/wopimirror/x", false},
{"/", false},
}
for _, tc := range cases {
app.Get(tc.path, func(c *fiber.Ctx) error {
if got := isWOPIRequest(c); got != tc.want {
t.Errorf("isWOPIRequest(%q) = %v, want %v", tc.path, got, tc.want)
}
return c.SendStatus(fiber.StatusOK)
})
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil), -1)
if err != nil {
t.Fatalf("app.Test(%q): %v", tc.path, err)
}
_ = resp.Body.Close()
}
}
// Regression for the "template .xlsx always corrupt via WOPI" bug: the global
// gzip compress middleware must NOT compress WOPI file-content responses.
// Collabora saves the response body verbatim as the document, so a gzip stream
// there is a guaranteed-corrupt file.
func TestCompressMiddleware_SkipsWOPIContents(t *testing.T) {
src := buildTinyXLSX(t)
assertValidZip(t, src)
app := fiber.New()
// Mirror the production compress wiring (UseDefaultMiddlewares).
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
Next: func(c *fiber.Ctx) bool {
return isEventStreamRequest(c) || isWOPIRequest(c)
},
}))
streamXLSX := func(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "application/octet-stream")
c.Status(fiber.StatusOK)
c.Context().Response.SetBodyStream(io.NopCloser(bytes.NewReader(src)), len(src))
return nil
}
app.Get("/wopi/files/:id/contents", streamXLSX)
// Negative control: a non-WOPI route with a compressible body must still gzip.
app.Get("/api/echo", func(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "application/octet-stream")
return c.Send(bytes.Repeat([]byte("A"), 4096))
})
// WOPI content download with gzip offered must come back raw + valid.
req := httptest.NewRequest("GET", "/wopi/files/abc/contents", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test wopi: %v", err)
}
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
t.Fatalf("WOPI content unexpectedly encoded: Content-Encoding=%q", enc)
}
got, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
assertValidZip(t, got)
if !bytes.Equal(got, src) {
t.Fatalf("WOPI content body mutated: got %d bytes, want %d", len(got), len(src))
}
// Sanity: prove compression is actually active for non-excluded routes.
req2 := httptest.NewRequest("GET", "/api/echo", nil)
req2.Header.Set("Accept-Encoding", "gzip")
resp2, err := app.Test(req2, -1)
if err != nil {
t.Fatalf("app.Test echo: %v", err)
}
if enc := resp2.Header.Get("Content-Encoding"); enc != "gzip" {
t.Fatalf("expected gzip on non-WOPI route, got Content-Encoding=%q (compress not active - test invalid)", enc)
}
_ = resp2.Body.Close()
}

View File

@@ -0,0 +1,471 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/helmet"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/gofiber/fiber/v2/middleware/timeout"
"github.com/gofiber/fiber/v2/utils"
"github.com/prometheus/client_golang/prometheus"
"wucher/internal/config"
"wucher/internal/shared/pkg/apperrorsx"
)
var (
httpMetricsRegisterOnce = sync.Once{}
httpRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"method", "path", "status"})
httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path", "status"})
)
// UseDefaultMiddlewares applies baseline best-practice middleware.
func UseDefaultMiddlewares(app *fiber.App, cfg config.HTTPConfig) {
registerHTTPMetrics()
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: func(c *fiber.Ctx, err any) {
slog.Error("panic recovered", "method", c.Method(), "path", c.Path(), "error", err, "stack", string(debug.Stack()))
},
}))
app.Use(requestid.New(requestid.Config{
Header: fiber.HeaderXRequestID,
ContextKey: "requestid",
Generator: utils.UUIDv4,
}))
app.Use(func(c *fiber.Ctx) error {
// Use a request-scoped context that keeps request values but is not canceled
// as soon as fasthttp starts draining on server shutdown.
c.SetUserContext(context.WithoutCancel(c.Context()))
return c.Next()
})
app.Use(httpAccessLogMiddleware())
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
Next: func(c *fiber.Ctx) bool {
return isEventStreamRequest(c) || isWOPIRequest(c)
},
}))
app.Use(requestTimeoutMiddleware(cfg))
app.Use(httpMetricsMiddleware())
app.Use(helmet.New(helmet.Config{
XFrameOptions: "DENY",
ReferrerPolicy: "no-referrer",
PermissionPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
HSTSMaxAge: cfg.HSTSMaxAge,
HSTSPreloadEnabled: cfg.HSTSMaxAge > 0,
}))
app.Use(disallowUnsafeMethods())
corsAllowCredentials := cfg.CORSAllowCredentials && !containsWildcardOrigin(cfg.CORSAllowOrigins)
app.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORSAllowOrigins,
AllowMethods: cfg.CORSAllowMethods,
AllowHeaders: cfg.CORSAllowHeaders,
ExposeHeaders: cfg.CORSExposeHeaders,
AllowCredentials: corsAllowCredentials,
MaxAge: cfg.CORSMaxAge,
}))
app.Use(limiter.New(limiter.Config{
Max: cfg.RateLimitMax,
Expiration: cfg.RateLimitWindow,
Next: func(c *fiber.Ctx) bool {
path := c.Path()
return c.Method() == fiber.MethodOptions ||
path == "/health" ||
strings.HasPrefix(path, "/swagger")
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(cfg.RateLimitWindow.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
}))
// Per-endpoint limiter (exact method+path match), configured from env/config.
// Applied in addition to the global limiter above.
endpointKeys := make([]string, 0, len(cfg.RateLimitByEndpoint))
for key := range cfg.RateLimitByEndpoint {
endpointKeys = append(endpointKeys, key)
}
sort.Strings(endpointKeys)
for _, endpointKey := range endpointKeys {
rule := cfg.RateLimitByEndpoint[endpointKey]
currentEndpointKey := endpointKey
currentRule := rule
app.Use(limiter.New(limiter.Config{
Max: currentRule.Max,
Expiration: currentRule.Window,
Next: func(c *fiber.Ctx) bool {
return methodPathKey(c.Method(), c.Path()) != currentEndpointKey
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(currentRule.Window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": fmt.Sprintf("rate limit exceeded for %s", currentEndpointKey),
}},
})
},
}))
}
}
func httpAccessLogMiddleware() fiber.Handler {
requestLogger := slog.Default()
return func(c *fiber.Ctx) error {
started := time.Now()
err := c.Next()
if c.Path() == "/health" {
return err
}
statusCode := c.Response().StatusCode()
level := slog.LevelInfo
if statusCode >= fiber.StatusInternalServerError || err != nil {
level = slog.LevelError
} else if statusCode >= fiber.StatusBadRequest {
level = slog.LevelWarn
}
requestID := strings.TrimSpace(fmt.Sprint(c.Locals("requestid")))
if requestID == "" || requestID == "<nil>" {
requestID = strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
}
args := []any{
slog.String("request_id", requestID),
slog.String("method", c.Method()),
slog.String("path", c.Path()),
slog.Int("status", statusCode),
slog.Duration("latency", time.Since(started)),
slog.String("ip", c.IP()),
}
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail))); detail != "" && detail != "<nil>" {
args = append(args, slog.String("internal_detail", detail))
}
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorDetail))); detail != "" && detail != "<nil>" {
args = append(args, slog.String("detail", detail))
}
if err != nil {
args = append(args, slog.Any("error", err))
args = append(args, slog.Any("error_chain", buildErrorChain(err)))
}
if statusCode >= fiber.StatusInternalServerError {
if body := strings.TrimSpace(string(c.Response().Body())); body != "" {
args = append(args, slog.String("response_body", truncateForLog(body, 1800)))
}
}
requestLogger.Log(c.UserContext(), level, "http request", args...)
return err
}
}
func buildErrorChain(err error) []string {
if err == nil {
return nil
}
chain := make([]string, 0, 8)
seen := map[string]struct{}{}
for current := err; current != nil; current = errors.Unwrap(current) {
msg := current.Error()
if msg == "" {
msg = fmt.Sprintf("%T", current)
}
key := fmt.Sprintf("%T:%s", current, msg)
if _, ok := seen[key]; ok {
break
}
seen[key] = struct{}{}
chain = append(chain, fmt.Sprintf("%T: %s", current, msg))
}
return chain
}
func truncateForLog(v string, max int) string {
if max <= 0 || len(v) <= max {
return v
}
if max <= 3 {
return v[:max]
}
return v[:max-3] + "..."
}
func httpMetricsMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
started := time.Now()
method := normalizeHTTPMethod(c.Method())
err := c.Next()
path := metricsPathLabel(c)
if shouldSkipHTTPMetrics(path) {
return err
}
status := strconv.Itoa(c.Response().StatusCode())
httpRequestsTotal.WithLabelValues(method, path, status).Inc()
httpRequestDurationSeconds.WithLabelValues(method, path, status).Observe(time.Since(started).Seconds())
return err
}
}
func registerHTTPMetrics() {
httpMetricsRegisterOnce.Do(func() {
registerHTTPCollector(httpRequestsTotal)
registerHTTPCollector(httpRequestDurationSeconds)
})
}
func registerHTTPCollector(collector prometheus.Collector) {
if err := prometheus.Register(collector); err != nil {
var alreadyRegistered prometheus.AlreadyRegisteredError
if errors.As(err, &alreadyRegistered) {
return
}
panic(err)
}
}
func shouldSkipHTTPMetrics(path string) bool {
return path == "/metrics" || path == "/health"
}
func metricsPathLabel(c *fiber.Ctx) string {
route := c.Route()
if route != nil {
if routePath := strings.TrimSpace(utils.CopyString(route.Path)); routePath != "" {
return routePath
}
}
return "unmatched"
}
func normalizeHTTPMethod(method string) string {
method = strings.ToUpper(strings.TrimSpace(utils.CopyString(method)))
if method == "" {
return "UNKNOWN"
}
switch method {
case fiber.MethodGet,
fiber.MethodHead,
fiber.MethodPost,
fiber.MethodPut,
fiber.MethodPatch,
fiber.MethodDelete,
fiber.MethodOptions,
fiber.MethodTrace,
fiber.MethodConnect:
return method
default:
return "OTHER"
}
}
func requestTimeoutMiddleware(cfg config.HTTPConfig) fiber.Handler {
defaultTimeout := cfg.RequestTimeout
overrides := cfg.RequestTimeoutByPath
if defaultTimeout <= 0 && len(overrides) == 0 {
return func(c *fiber.Ctx) error { return c.Next() }
}
return func(c *fiber.Ctx) error {
if isEventStreamRequest(c) {
return c.Next()
}
requestTimeout := defaultTimeout
if override, ok := timeoutForRequest(overrides, c.Method(), c.Path(), c.Route()); ok {
requestTimeout = override
}
if requestTimeout <= 0 {
return c.Next()
}
return timeout.NewWithContext(func(c *fiber.Ctx) error {
return c.Next()
}, requestTimeout)(c)
}
}
func isEventStreamRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
path := strings.TrimSpace(c.Path())
if strings.HasSuffix(path, "/file-manager/events") {
return true
}
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
return strings.Contains(accept, "text/event-stream")
}
// isWOPIRequest reports whether the request targets the Collabora/WOPI
// endpoints. WOPI file-content responses carry raw office bytes (an .xlsx is a
// ZIP container that is already compressed); gzipping them makes Collabora
// store the compressed stream verbatim as the document, producing a file that
// is consistently corrupt. Office/JSON WOPI payloads gain nothing from gzip, so
// the whole group is excluded from the compress middleware.
func isWOPIRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
return strings.HasPrefix(strings.TrimSpace(c.Path()), "/wopi/")
}
func timeoutForRequest(overrides map[string]time.Duration, method, path string, route *fiber.Route) (time.Duration, bool) {
if len(overrides) == 0 {
return 0, false
}
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
trimmedPath := strings.TrimSpace(path)
if route != nil {
registeredPath := strings.TrimSpace(route.Path)
if registeredPath != "" && registeredPath != "/" {
key := timeoutOverrideKey(normalizedMethod, registeredPath)
if v, ok := overrides[key]; ok {
return v, true
}
}
}
if v, ok := overrides[timeoutOverrideKey(normalizedMethod, trimmedPath)]; ok {
return v, true
}
for key, duration := range overrides {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 {
continue
}
if strings.ToUpper(strings.TrimSpace(parts[0])) != normalizedMethod {
continue
}
if routePatternMatches(strings.TrimSpace(parts[1]), trimmedPath) {
return duration, true
}
}
return 0, false
}
func methodPathKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
func timeoutOverrideKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
func routePatternMatches(pattern, path string) bool {
if pattern == "" {
return false
}
if pattern == path {
return true
}
pSeg := splitPathSegments(pattern)
rSeg := splitPathSegments(path)
if len(pSeg) == 0 && len(rSeg) == 0 {
return true
}
i := 0
for ; i < len(pSeg); i++ {
segment := pSeg[i]
if segment == "*" || strings.HasPrefix(segment, "*") {
return true
}
if i >= len(rSeg) {
return false
}
if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "+") {
continue
}
if segment != rSeg[i] {
return false
}
}
return i == len(rSeg)
}
func splitPathSegments(path string) []string {
trimmed := strings.TrimSpace(path)
trimmed = strings.TrimPrefix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func disallowUnsafeMethods() fiber.Handler {
return func(c *fiber.Ctx) error {
if c.Method() == fiber.MethodTrace || c.Method() == "TRACK" {
return c.SendStatus(fiber.StatusMethodNotAllowed)
}
return c.Next()
}
}
func containsWildcardOrigin(origins string) bool {
for _, origin := range strings.Split(origins, ",") {
if strings.TrimSpace(origin) == "*" {
return true
}
}
return false
}

View File

@@ -0,0 +1,586 @@
package api
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"wucher/internal/config"
"wucher/internal/shared/pkg/apperrorsx"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
)
func testHTTPConfig() config.HTTPConfig {
return config.HTTPConfig{
CORSAllowOrigins: "http://localhost:3000",
CORSAllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
CORSAllowHeaders: "Origin,Content-Type,Accept,Authorization",
CORSExposeHeaders: "X-Request-ID",
CORSAllowCredentials: true,
CORSMaxAge: 300,
RateLimitMax: 100,
RateLimitWindow: time.Minute,
RequestTimeout: 0,
RequestTimeoutByPath: map[string]time.Duration{},
HSTSMaxAge: 0,
}
}
func TestUseDefaultMiddlewares_CORSHeaders(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("expected allow-origin http://localhost:3000, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("expected allow-credentials true, got %q", got)
}
if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("expected X-Frame-Options DENY, got %q", got)
}
if got := resp.Header.Get("X-Request-Id"); got == "" {
t.Fatalf("expected request id header to be set")
}
}
func TestUseDefaultMiddlewares_BlockTrace(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.All("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodTrace, "/ping", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RateLimiter(t *testing.T) {
cfg := testHTTPConfig()
cfg.RateLimitMax = 1
cfg.RateLimitWindow = time.Minute
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/limited", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
firstReq, _ := http.NewRequest(http.MethodGet, "/limited", nil)
firstResp, err := app.Test(firstReq)
if err != nil {
t.Fatalf("first request app.Test: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", firstResp.StatusCode)
}
secondReq, _ := http.NewRequest(http.MethodGet, "/limited", nil)
secondResp, err := app.Test(secondReq)
if err != nil {
t.Fatalf("second request app.Test: %v", err)
}
if secondResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", secondResp.StatusCode)
}
if got := secondResp.Header.Get("Retry-After"); got == "" {
t.Fatalf("expected Retry-After header on 429 response")
}
}
func TestUseDefaultMiddlewares_EndpointRateLimiter(t *testing.T) {
cfg := testHTTPConfig()
cfg.RateLimitMax = 100
cfg.RateLimitByEndpoint = map[string]config.RateLimitRule{
"POST /auth/login": {
Max: 1,
Window: time.Minute,
},
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Post("/auth/login", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
app.Post("/auth/refresh", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req1, _ := http.NewRequest(http.MethodPost, "/auth/login", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first login request app.Test: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first login request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/auth/login", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second login request app.Test: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second login request 429, got %d", resp2.StatusCode)
}
otherReq, _ := http.NewRequest(http.MethodPost, "/auth/refresh", nil)
otherResp, err := app.Test(otherReq)
if err != nil {
t.Fatalf("other endpoint request app.Test: %v", err)
}
if otherResp.StatusCode != http.StatusOK {
t.Fatalf("expected other endpoint request 200, got %d", otherResp.StatusCode)
}
}
func TestUseDefaultMiddlewares_WildcardOriginDisablesCredentials(t *testing.T) {
cfg := testHTTPConfig()
cfg.CORSAllowOrigins = "*"
cfg.CORSAllowCredentials = true
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("expected allow-origin *, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("expected allow-credentials to be omitted, got %q", got)
}
}
func TestUseDefaultMiddlewares_RequestContextDetachedFromShutdown(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ctx", func(c *fiber.Ctx) error {
if c.UserContext() == nil {
return c.SendStatus(http.StatusInternalServerError)
}
if c.UserContext().Done() != nil {
return c.SendStatus(http.StatusInternalServerError)
}
return c.SendStatus(http.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/ctx", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_CompressesLargeJSONWhenGzipAccepted(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
largePayload := map[string]string{
"data": strings.Repeat("x", 8*1024),
}
app.Get("/large-json", func(c *fiber.Ctx) error {
return c.JSON(largePayload)
})
req, _ := http.NewRequest(http.MethodGet, "/large-json", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("expected Content-Encoding gzip, got %q", got)
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer gz.Close()
var payload map[string]string
if err := json.NewDecoder(gz).Decode(&payload); err != nil {
t.Fatalf("decode json: %v", err)
}
if payload["data"] != largePayload["data"] {
t.Fatalf("unexpected payload body")
}
}
func TestUseDefaultMiddlewares_RequestTimeoutExceeded(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-time.After(50 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/slow", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusRequestTimeout {
t.Fatalf("expected 408, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutNormalRequestUnaffected(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 200 * time.Millisecond
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/fast", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/fast", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverride(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
cfg.RequestTimeoutByPath = map[string]time.Duration{
"GET /slow": 120 * time.Millisecond,
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-time.After(40 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/slow", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with override timeout, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverrideDynamicRoute(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
cfg.RequestTimeoutByPath = map[string]time.Duration{
"GET /users/:id": 120 * time.Millisecond,
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/users/:id", func(c *fiber.Ctx) error {
select {
case <-time.After(40 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/users/123", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with dynamic route override timeout, got %d", resp.StatusCode)
}
}
func TestHTTPAccessLogMiddlewareIncludesErrorDetail(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/bad", func(c *fiber.Ctx) error {
return response.WriteErrors(c, http.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Title: "Validation error",
Detail: "name is required",
}})
})
req, _ := http.NewRequest(http.MethodGet, "/bad", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "name is required") {
t.Fatalf("expected sanitized response detail, got %s", string(body))
}
if !strings.Contains(string(body), `"detail":"validation failed"`) {
t.Fatalf("expected generic response detail, got %s", string(body))
}
if !strings.Contains(logBuf.String(), "name is required") {
t.Fatalf("expected access log to include original detail, got %s", logBuf.String())
}
}
func TestHTTPAccessLogMiddlewareIncludesResponseBodyOnServerError(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/boom", func(c *fiber.Ctx) error {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "500",
"title": "Update failed",
"detail": "db deadlock on takeover update",
}},
})
})
req, _ := http.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if !strings.Contains(logBuf.String(), "db deadlock on takeover update") {
t.Fatalf("expected access log to include response body detail, got %s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "response_body") {
t.Fatalf("expected access log to include response_body field, got %s", logBuf.String())
}
}
func TestHTTPAccessLogMiddlewareIncludesInternalErrorDetail(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/boom", func(c *fiber.Ctx) error {
c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail, "action=UpdateTakeover | error=reserve update failed | chain=*errors.errorString: reserve update failed")
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "500",
"title": "Update failed",
"detail": "internal server error",
}},
})
})
req, _ := http.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if !strings.Contains(logBuf.String(), "internal_detail") {
t.Fatalf("expected access log to include internal_detail field, got %s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "reserve update failed") {
t.Fatalf("expected access log to include internal error detail, got %s", logBuf.String())
}
}
func TestUseDefaultMiddlewares_HTTPMetricsUsesRoutePatternLabels(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/users/:id", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
app.Get("/metrics", func(c *fiber.Ctx) error {
metricsHandler(c.Context())
return nil
})
beforeCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201")
beforeHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201")
req, _ := http.NewRequest(http.MethodGet, "/users/123", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
afterCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201")
afterHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201")
if afterCounter <= beforeCounter {
t.Fatalf("expected counter to increase, before=%v after=%v", beforeCounter, afterCounter)
}
if afterHistogram <= beforeHistogram {
t.Fatalf("expected histogram count to increase, before=%v after=%v", beforeHistogram, afterHistogram)
}
metricsReq, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp, err := app.Test(metricsReq)
if err != nil {
t.Fatalf("metrics app.Test: %v", err)
}
if metricsResp.StatusCode != http.StatusOK {
t.Fatalf("expected /metrics status 200, got %d", metricsResp.StatusCode)
}
body, err := io.ReadAll(metricsResp.Body)
if err != nil {
t.Fatalf("read /metrics body: %v", err)
}
metricsBody := string(body)
if !strings.Contains(metricsBody, "http_requests_total") {
t.Fatalf("expected http_requests_total in /metrics output")
}
if !strings.Contains(metricsBody, "http_request_duration_seconds") {
t.Fatalf("expected http_request_duration_seconds in /metrics output")
}
if !strings.Contains(metricsBody, "path=\"/users/:id\"") {
t.Fatalf("expected route-pattern path label in /metrics output")
}
beforeMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200")
beforeMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200")
metricsReq2, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp2, err := app.Test(metricsReq2)
if err != nil {
t.Fatalf("metrics second app.Test: %v", err)
}
if metricsResp2.StatusCode != http.StatusOK {
t.Fatalf("expected second /metrics status 200, got %d", metricsResp2.StatusCode)
}
afterMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200")
afterMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200")
if afterMetricsCounter != beforeMetricsCounter {
t.Fatalf("expected /metrics to be excluded from counter, before=%v after=%v", beforeMetricsCounter, afterMetricsCounter)
}
if afterMetricsHistogram != beforeMetricsHistogram {
t.Fatalf("expected /metrics to be excluded from histogram, before=%v after=%v", beforeMetricsHistogram, afterMetricsHistogram)
}
}
func TestNormalizeHTTPMethod(t *testing.T) {
tests := []struct {
name string
input string
output string
}{
{name: "get", input: "GET", output: "GET"},
{name: "lowercase", input: "post", output: "POST"},
{name: "trimmed", input: " put ", output: "PUT"},
{name: "empty", input: "", output: "UNKNOWN"},
{name: "custom", input: "GETT", output: "OTHER"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeHTTPMethod(tt.input); got != tt.output {
t.Fatalf("expected %q, got %q", tt.output, got)
}
})
}
}
func metricCounterValue(t *testing.T, vec *prometheus.CounterVec, labels ...string) float64 {
t.Helper()
m := &dto.Metric{}
if err := vec.WithLabelValues(labels...).Write(m); err != nil {
t.Fatalf("read counter metric: %v", err)
}
return m.GetCounter().GetValue()
}
func metricHistogramCount(t *testing.T, vec *prometheus.HistogramVec, labels ...string) uint64 {
t.Helper()
observer, err := vec.GetMetricWithLabelValues(labels...)
if err != nil {
t.Fatalf("load histogram metric: %v", err)
}
metric, ok := observer.(prometheus.Metric)
if !ok {
t.Fatalf("histogram observer does not implement prometheus.Metric")
}
m := &dto.Metric{}
if err := metric.Write(m); err != nil {
t.Fatalf("read histogram metric: %v", err)
}
if m.GetHistogram() == nil {
t.Fatalf("histogram metric not available for labels %v", labels)
}
return m.GetHistogram().GetSampleCount()
}

View File

@@ -0,0 +1,9 @@
package api
type PinProtectedEndpoint struct {
Method string
Path string
Action string
}
var PinProtectedEndpoints = []PinProtectedEndpoint{}

View File

@@ -0,0 +1,64 @@
package api
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestPinProtectedEndpoints_Definition(t *testing.T) {
seen := map[string]struct{}{}
for _, ep := range PinProtectedEndpoints {
if ep.Method == "" || ep.Path == "" || ep.Action == "" {
t.Fatalf("invalid pin-protected endpoint: %#v", ep)
}
key := ep.Method + " " + ep.Path
if _, ok := seen[key]; ok {
t.Fatalf("duplicate pin-protected endpoint %s", key)
}
seen[key] = struct{}{}
}
}
func TestPinProtectedEndpoints_RoutesHavePinMiddleware(t *testing.T) {
if len(PinProtectedEndpoints) == 0 {
return
}
app := fiber.New()
RegisterRoutes(app, buildRouteDependenciesForTest())
routes := app.GetRoutes(true)
for _, ep := range PinProtectedEndpoints {
route := findRoute(routes, ep.Method, ep.Path)
if route == nil {
t.Fatalf("pin-protected route %s %s is not registered", ep.Method, ep.Path)
}
minHandlers := 3 // pin + permission + handler
if ep.Path == "/api/v1/auth/invite" {
minHandlers = 4 // auth + pin + permission + handler (no group auth middleware)
}
if len(route.Handlers) < minHandlers {
t.Fatalf("route %s %s expected at least %d handlers, got %d", ep.Method, ep.Path, minHandlers, len(route.Handlers))
}
}
}
func findRoute(routes []fiber.Route, method, path string) *fiber.Route {
for i := range routes {
if routes[i].Method == method && routes[i].Path == path {
return &routes[i]
}
}
return nil
}
func TestPinProtectedEndpoints_ExpectedMethods(t *testing.T) {
for _, ep := range PinProtectedEndpoints {
switch ep.Method {
case http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete:
default:
t.Fatalf("unexpected method %q on pin-protected endpoint %s", ep.Method, ep.Path)
}
}
}

140
internal/app/api/routes.go Normal file
View File

@@ -0,0 +1,140 @@
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
samlauth "wucher/internal/auth"
"wucher/internal/service"
"wucher/internal/shared/pkg/metrics"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
var metricsHandler = fasthttpadaptor.NewFastHTTPHandler(metrics.PromHTTPHandler())
type RouteServices struct {
Auth *service.AuthService
Audit *service.AuditLogService
WOPI *service.WOPIService
}
type RouteHandlers 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
HEMSOperationalData *handlers.HEMSOperationalDataHandler
HEMSOperationCategory *handlers.HEMSOperationCategoryHandler
HEMSOperation *handlers.HEMSOperationHandler
Complaint *handlers.ComplaintHandler
EASARelease *handlers.EASAReleaseHandler
ActionSignoff *handlers.ActionSignoffHandler
MCF *handlers.MCFHandler
DUL *handlers.DULHandler
FleetStatus *handlers.FleetStatusHandler
SAMLAuth *samlauth.Handler
}
type RouteDependencies struct {
Handlers RouteHandlers
Services RouteServices
}
func RegisterRoutes(app *fiber.App, deps RouteDependencies) {
h := deps.Handlers
s := deps.Services
app.Get("/health", h.Health.Handle)
app.Get("/version", h.BuildInfo.Handle)
app.Get("/api/version", h.BuildInfo.Handle)
app.Get("/api/build-info", h.BuildInfo.Handle)
app.Get("/metrics", func(c *fiber.Ctx) error {
metricsHandler(c.Context())
return nil
})
api := app.Group("/api")
api.Use(AuditMiddleware(s.Audit))
v1 := api.Group("/v1")
registerAuthRoutes(v1, h.Auth, s.Auth, h.SAMLAuth)
registerUserRoutes(v1, h.User, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerContactRoutes(v1, h.Contact, middleware.AuthRequired(s.Auth), s.Auth)
registerRoleRoutes(v1, h.Role, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterRoutes(v1, h.Helicopter, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterUsageRoutes(v1, h.HelicopterUsage, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterFileRoutes(v1, h.HelicopterFile, h.HelicopterFileEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFlightRoutes(v1, h.Flight, middleware.AuthRequired(s.Auth), s.Auth)
registerFlightDataRoutes(v1, h.FlightData, middleware.AuthRequired(s.Auth), s.Auth)
registerMissionRoutes(v1, h.Mission, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFMReportRoutes(v1, h.FMReport, middleware.AuthRequired(s.Auth), s.Auth)
registerReserveAcRoutes(v1, h.ReserveAc, h.Takeover, middleware.AuthRequired(s.Auth), s.Auth)
registerTakeoverRoutes(v1, h.Takeover, middleware.AuthRequired(s.Auth), s.Auth)
registerOtherPersonRoutes(v1, h.OtherPerson, middleware.AuthRequired(s.Auth), s.Auth)
registerLastFlightsInspectionRoutes(v1, h.ReserveAc, middleware.AuthRequired(s.Auth), s.Auth)
registerFacilityRoutes(v1, h.Facility, middleware.AuthRequired(s.Auth), s.Auth)
registerBaseRoutes(v1, h.Base, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerMedicineRoutes(v1, h.Medicine, middleware.AuthRequired(s.Auth), s.Auth)
registerVocationRoutes(v1, h.Vocation, middleware.AuthRequired(s.Auth), s.Auth)
registerOpcRoutes(v1, h.Opc, middleware.AuthRequired(s.Auth), s.Auth)
registerForcesPresentRoutes(v1, h.ForcesPresent, middleware.AuthRequired(s.Auth), s.Auth)
registerHospitalRoutes(v1, h.Hospital, middleware.AuthRequired(s.Auth), s.Auth)
registerHealthInsuranceCompaniesRoutes(v1, h.HealthInsuranceCompanies, middleware.AuthRequired(s.Auth), s.Auth)
registerFederalStateRoutes(v1, h.FederalState, middleware.AuthRequired(s.Auth), s.Auth)
registerICAORoutes(v1, h.ICAO, middleware.AuthRequired(s.Auth), s.Auth)
registerLandRoutes(v1, h.Land, middleware.AuthRequired(s.Auth), s.Auth)
registerMasterSettingsRoutes(v1, h.MasterSettings, middleware.AuthRequired(s.Auth), s.Auth)
registerBrandingRoutes(v1, h.Branding)
registerFileManagerRoutes(v1, h.FileManagerFolder, h.FileManagerFile, h.FileManagerAttachment, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerWOPIRoutes(app, h.Takeover, s.WOPI)
registerDutyRosterRoutes(v1, h.DutyRoster, middleware.AuthRequired(s.Auth), s.Auth)
registerAirRescuerChecklistRoutes(v1, h.AirRescuerChecklist, middleware.AuthRequired(s.Auth), s.Auth)
registerInsurancePatientDataRoutes(v1, h.InsurancePatientData, middleware.AuthRequired(s.Auth), s.Auth)
registerPatientDataRoutes(v1, h.PatientData, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationalDataRoutes(v1, h.HEMSOperationalData, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationCategoryRoutes(v1, h.HEMSOperationCategory, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationRoutes(v1, h.HEMSOperation, middleware.AuthRequired(s.Auth), s.Auth)
registerComplaintRoutes(v1, h.Complaint, middleware.AuthRequired(s.Auth), s.Auth)
registerEASAReleaseRoutes(v1, h.EASARelease, middleware.AuthRequired(s.Auth), s.Auth)
registerActionSignoffRoutes(v1, h.ActionSignoff, middleware.AuthRequired(s.Auth), s.Auth)
registerMCFRoutes(v1, h.MCF, middleware.AuthRequired(s.Auth), s.Auth)
registerDULRoutes(v1, h.DUL, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFleetStatusRoutes(v1, h.FleetStatus, middleware.AuthRequired(s.Auth), s.Auth)
registerAuditRoutes(v1, handlers.NewAuditHandler(s.Audit), middleware.AuthRequired(s.Auth), s.Auth)
registerSwaggerRoutes(app)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerActionSignoffRoutes(v1 fiber.Router, h *handlers.ActionSignoffHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/action-signoffs", authMiddleware)
g.Get("/get-by-flight/:flight_id", middleware.PermissionRequired(authSvc, "action_signoff.read"), h.GetByFlight)
g.Post("/sign/:helicopter_id", middleware.PermissionRequired(authSvc, "action_signoff.sign"), h.Sign)
}

View File

@@ -0,0 +1,23 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAirRescuerChecklistRoutes(v1 fiber.Router, checklistHandler *handlers.AirRescuerChecklistHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
checklists := v1.Group("/air-rescuer-checklist", authMiddleware)
checklists.Post("/create", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.create"), checklistHandler.Create)
checklists.Get("/get-all", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.List)
checklists.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.ListDatatable)
checklists.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.Get)
checklists.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.Update)
checklists.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.delete"), checklistHandler.Delete)
checklists.Post("/:uuid/items/create", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.CreateItem)
checklists.Patch("/:uuid/items/reorder", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.ReorderItems)
checklists.Patch("/items/update/:item_uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.UpdateItem)
checklists.Delete("/items/delete/:item_uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.delete"), checklistHandler.DeleteItem)
}

View File

@@ -0,0 +1,14 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuditRoutes(v1 fiber.Router, auditHandler *handlers.AuditHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
audits := v1.Group("/audit-logs", authMiddleware)
audits.Get("/get-all", middleware.PermissionRequiredReusable(authSvc, "audit.read"), auditHandler.List)
}

View File

@@ -0,0 +1,142 @@
package api
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
samlauth "wucher/internal/auth"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuthRoutes(v1 fiber.Router, authHandler *handlers.AuthHandler, authSvc *service.AuthService, samlHandler *samlauth.Handler) {
auth := v1.Group("/auth")
auth.Post("/register", authHandler.Register)
auth.Post("/invite", middleware.AuthRequired(authSvc), middleware.PermissionRequired(authSvc, "user.create"), authHandler.Invite)
auth.Post("/invite/resend-set-password", middleware.AuthRequired(authSvc), middleware.PermissionRequired(authSvc, "user.update"), forgotPasswordIPLimiter(authSvc), authHandler.ResendSetPasswordInvite)
auth.Post("/set-password", authHandler.SetPassword)
auth.Post("/forgot-password", forgotPasswordIPLimiter(authSvc), authHandler.ForgotPassword)
auth.Post("/reset-password", authHandler.ResetPassword)
auth.Post("/pin/setup", middleware.AuthRequired(authSvc), authHandler.SetupSecurityPIN)
auth.Post("/pin/verify", middleware.AuthRequired(authSvc), authHandler.VerifySecurityPIN)
auth.Post("/pin/change", middleware.AuthRequired(authSvc), authHandler.ChangeSecurityPIN)
auth.Post("/pin/request-reset", middleware.AuthRequired(authSvc), forgotPasswordIPLimiter(authSvc), authHandler.RequestSecurityPINReset)
auth.Post("/pin/forgot", forgotPasswordIPLimiter(authSvc), authHandler.ForgotSecurityPIN)
auth.Post("/pin/reset", authHandler.ResetSecurityPIN)
auth.Post("/login", loginIPLimiter(authSvc), authHandler.Login)
auth.Post("/exchange", authHandler.Exchange)
auth.Post("/webauthn/register/begin", middleware.AuthRequired(authSvc), authHandler.WebAuthnRegisterBegin)
auth.Post("/webauthn/register/finish", middleware.AuthRequired(authSvc), authHandler.WebAuthnRegisterFinish)
auth.Post("/webauthn/login/begin", authHandler.WebAuthnLoginBegin)
auth.Post("/webauthn/login/finish", authHandler.WebAuthnLoginFinish)
auth.Post("/webauthn/reset-setup", middleware.AuthRequired(authSvc), authHandler.WebAuthnResetSetup)
auth.Post("/refresh", authHandler.Refresh)
auth.Post("/logout", authHandler.Logout)
auth.Get("/sessions", middleware.AuthRequired(authSvc), authHandler.Sessions)
auth.Delete("/sessions/:session_id", middleware.AuthRequired(authSvc), authHandler.DeleteSession)
auth.Get("/me", middleware.AuthRequired(authSvc), authHandler.Me)
auth.Patch("/me/timezone", middleware.AuthRequired(authSvc), authHandler.UpdateTimezone)
auth.Get("/verify-email", authHandler.VerifyEmail)
if samlHandler != nil {
auth.Get("/microsoft/login", samlHandler.HandleLogin)
auth.Get("/microsoft/metadata", samlHandler.HandleMetadata)
} else {
auth.Get("/microsoft/login", authHandler.MicrosoftLogin)
}
auth.Get("/microsoft/login-check", authHandler.MicrosoftLoginCheck)
auth.Get("/microsoft/link", middleware.AuthRequired(authSvc), authHandler.MicrosoftLink)
auth.Get("/microsoft/reauth", middleware.AuthRequired(authSvc), authHandler.MicrosoftReauth)
if samlHandler != nil {
auth.Get("/microsoft/callback", samlHandler.HandleSAMLCallback)
auth.Post("/microsoft/callback", samlHandler.HandleSAMLCallback)
auth.Get("/microsoft/logout", func(c *fiber.Ctx) error {
if c.Query("SAMLRequest") != "" {
return samlHandler.HandleSAMLLogoutRequest(c)
}
if c.Query("SAMLResponse") != "" {
return samlHandler.HandleSAMLLogoutResponse(c)
}
return samlHandler.HandleBrowserLocalLogout(c)
})
auth.Post("/microsoft/logout", func(c *fiber.Ctx) error {
if c.FormValue("SAMLRequest") != "" {
return samlHandler.HandleSAMLLogoutRequest(c)
}
if c.FormValue("SAMLResponse") != "" {
return samlHandler.HandleSAMLLogoutResponse(c)
}
return samlHandler.HandleBrowserLocalLogout(c)
})
} else {
auth.Get("/microsoft/callback", authHandler.MicrosoftCallback)
auth.Post("/microsoft/logout", middleware.AuthRequired(authSvc), authHandler.MicrosoftLogout)
}
// OIDC front-channel logout: invoked by Microsoft Entra in a hidden iframe when the
// user signs out at the IdP. Must be public (the IdP has no app credentials) and
// available regardless of the active Microsoft auth mode.
auth.Get("/microsoft/front-channel-logout", authHandler.MicrosoftFrontChannelLogout)
auth.Post("/microsoft/front-channel-logout", authHandler.MicrosoftFrontChannelLogout)
auth.Post("/microsoft/refresh", middleware.AuthRequired(authSvc), authHandler.MicrosoftRefresh)
auth.Post("/sso/link", middleware.AuthRequired(authSvc), authHandler.SSOLink)
auth.Delete("/sso/unlink", middleware.AuthRequired(authSvc), authHandler.SSOUnlink)
auth.Post("/totp/setup", middleware.AuthRequired(authSvc), authHandler.TOTPSetup)
auth.Post("/totp/confirm", middleware.AuthRequired(authSvc), authHandler.TOTPConfirm)
auth.Post("/totp/verify", authHandler.TOTPVerify)
auth.Post("/email-otp/send", authHandler.SendEmailOTP)
auth.Post("/email-otp/verify", authHandler.VerifyEmailOTP)
auth.Post("/totp/disable", middleware.AuthRequired(authSvc), authHandler.TOTPDisable)
auth.Post("/totp/reset-setup", middleware.AuthRequired(authSvc), authHandler.TOTPResetSetup)
}
func forgotPasswordIPLimiter(authSvc *service.AuthService) fiber.Handler {
window := authSvc.ForgotPasswordIPRateWindow()
return limiter.New(limiter.Config{
Max: authSvc.ForgotPasswordIPRateMax(),
Expiration: window,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
})
}
func loginIPLimiter(authSvc *service.AuthService) fiber.Handler {
window := authSvc.LoginIPRateWindow()
return limiter.New(limiter.Config{
Max: authSvc.LoginIPRateMax(),
Expiration: window,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
})
}

View File

@@ -0,0 +1,154 @@
package api
import (
"net/http"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/config"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
)
func TestForgotPasswordIPLimiter(t *testing.T) {
cfg := config.AuthConfig{
ForgotPasswordIPRateMax: 1,
ForgotPasswordIPRateWindow: time.Minute,
}
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
app := fiber.New()
app.Post("/forgot", forgotPasswordIPLimiter(svc), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req1, _ := http.NewRequest(http.MethodPost, "/forgot", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/forgot", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
}
}
func TestLoginIPLimiter(t *testing.T) {
cfg := config.AuthConfig{
LoginIPRateMax: 1,
LoginIPRateWindow: time.Minute,
}
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
app := fiber.New()
app.Post("/login", loginIPLimiter(svc), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req1, _ := http.NewRequest(http.MethodPost, "/login", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/login", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
}
}
func TestRegisterAuthRoutes(t *testing.T) {
app := fiber.New()
v1 := app.Group("/api/v1")
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
authHandler := handlers.NewAuthHandler(svc)
registerAuthRoutes(v1, authHandler, svc, nil)
routes := app.GetRoutes(true)
required := []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/v1/auth/register"},
{method: http.MethodPost, path: "/api/v1/auth/invite"},
{method: http.MethodPost, path: "/api/v1/auth/invite/resend-set-password"},
{method: http.MethodPost, path: "/api/v1/auth/forgot-password"},
{method: http.MethodPost, path: "/api/v1/auth/reset-password"},
{method: http.MethodPost, path: "/api/v1/auth/pin/setup"},
{method: http.MethodPost, path: "/api/v1/auth/pin/verify"},
{method: http.MethodPost, path: "/api/v1/auth/pin/change"},
{method: http.MethodPost, path: "/api/v1/auth/pin/request-reset"},
{method: http.MethodPost, path: "/api/v1/auth/pin/forgot"},
{method: http.MethodPost, path: "/api/v1/auth/pin/reset"},
{method: http.MethodPost, path: "/api/v1/auth/login"},
{method: http.MethodPost, path: "/api/v1/auth/exchange"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/register/begin"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/register/finish"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/login/begin"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/login/finish"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/reset-setup"},
{method: http.MethodPost, path: "/api/v1/auth/refresh"},
{method: http.MethodGet, path: "/api/v1/auth/sessions"},
{method: http.MethodDelete, path: "/api/v1/auth/sessions/:session_id"},
{method: http.MethodGet, path: "/api/v1/auth/me"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/login"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/login-check"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/link"},
{method: http.MethodPost, path: "/api/v1/auth/sso/link"},
{method: http.MethodDelete, path: "/api/v1/auth/sso/unlink"},
{method: http.MethodPost, path: "/api/v1/auth/totp/verify"},
{method: http.MethodPost, path: "/api/v1/auth/totp/reset-setup"},
}
for _, tc := range required {
if !hasRoute(routes, tc.method, tc.path) {
t.Fatalf("route %s %s not registered", tc.method, tc.path)
}
}
var forgotRoute *fiber.Route
for i := range routes {
if routes[i].Method == http.MethodPost && routes[i].Path == "/api/v1/auth/forgot-password" {
forgotRoute = &routes[i]
break
}
}
if forgotRoute == nil {
t.Fatalf("forgot password route not found")
}
if len(forgotRoute.Handlers) < 2 {
t.Fatalf("expected forgot password route to have limiter and handler, got %d handlers", len(forgotRoute.Handlers))
}
var loginRoute *fiber.Route
for i := range routes {
if routes[i].Method == http.MethodPost && routes[i].Path == "/api/v1/auth/login" {
loginRoute = &routes[i]
break
}
}
if loginRoute == nil {
t.Fatalf("login route not found")
}
if len(loginRoute.Handlers) < 2 {
t.Fatalf("expected login route to have limiter and handler, got %d handlers", len(loginRoute.Handlers))
}
}

View File

@@ -0,0 +1,35 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerBaseRoutes(
v1 fiber.Router,
baseHandler *handlers.BaseHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
bases := v1.Group("/bases", authMiddleware)
bases.Get("/events", middleware.PermissionRequired(authSvc, "base.read"), eventsHandler.Stream)
bases.Post("/create",
middleware.PermissionRequired(authSvc, "base.create"),
baseHandler.CreateBase,
)
bases.Get("/get-all", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.ListBases)
bases.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.ListBasesDatatable)
bases.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.GetBase)
bases.Patch("/update/:uuid",
middleware.PermissionRequired(authSvc, "base.update"),
baseHandler.UpdateBase,
)
bases.Delete("/delete/:uuid",
middleware.PermissionRequired(authSvc, "base.delete"),
baseHandler.DeleteBase,
)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerBrandingRoutes(v1 fiber.Router, brandingHandler *handlers.BrandingHandler) {
if brandingHandler == nil {
return
}
branding := v1.Group("/branding")
branding.Get("/logo", brandingHandler.GetLogo)
branding.Get("/login-cover", brandingHandler.GetLoginCover)
branding.Get("/login-content", brandingHandler.GetLoginContent)
publicBranding := v1.Group("/public/branding")
publicBranding.Get("/email-logo", brandingHandler.GetEmailLogo)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerComplaintRoutes(v1 fiber.Router, h *handlers.ComplaintHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
complaints := v1.Group("/complaints", authMiddleware)
complaints.Post("/create", middleware.PermissionRequired(authSvc, "complaint.create"), h.Create)
complaints.Get("/get-all", middleware.PermissionRequired(authSvc, "complaint.read"), h.List)
complaints.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListDatatable)
complaints.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListByHelicopter)
complaints.Get("/hold-items/get-all", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListHoldItems)
complaints.Get("/hold-items/get-all/dt", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListHoldItemsDatatable)
complaints.Get("/get/:id", middleware.PermissionRequired(authSvc, "complaint.read"), h.GetByID)
complaints.Patch("/update/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.Update)
complaints.Post("/action-taken/create/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.ActionTaken)
complaints.Patch("/action-taken/update/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.UpdateActionTaken)
complaints.Post("/nsr/sign/:id", middleware.PermissionRequired(authSvc, "complaint.nsr"), h.NSRSign)
complaints.Post("/nsr/unsign/:id", middleware.PermissionRequired(authSvc, "complaint.nsr"), h.NSRUnsign)
complaints.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "complaint.delete"), h.Delete)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerContactRoutes(v1 fiber.Router, contactHandler *handlers.ContactHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
contacts := v1.Group("/contacts", authMiddleware)
contacts.Post("/create", middleware.PermissionRequired(authSvc, "user.create"), contactHandler.Create)
contacts.Get("/get-all", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.List)
contacts.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.ListDatatable)
contacts.Get("/:user_id", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.Get)
contacts.Patch("/:user_id", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.Update)
contacts.Delete("/:user_id", middleware.PermissionRequired(authSvc, "user.delete"), contactHandler.Delete)
contacts.Patch("/:user_id/status", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateContactStatus)
contacts.Patch("/:user_id/role", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateContactRole)
contacts.Patch("/:user_id/pilot-category", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdatePilotCategory)
contacts.Patch("/:user_id/license", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateLicense)
contacts.Patch("/:user_id/chief-flags", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateChiefFlags)
contacts.Post("/bulk-update", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.BulkUpdate)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerDULRoutes(
v1 fiber.Router,
dulHandler *handlers.DULHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
dul := v1.Group("/dul", authMiddleware)
dul.Get("/events", middleware.PermissionRequired(authSvc, "dul.read"), eventsHandler.Stream)
dul.Post("/create", middleware.PermissionRequired(authSvc, "dul.create"), dulHandler.Create)
dul.Get("/get-all", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.List)
dul.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.ListDatatable)
dul.Get("/get/:id", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.Get)
dul.Patch("/update/:id", middleware.PermissionRequired(authSvc, "dul.update"), dulHandler.Update)
dul.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "dul.delete"), dulHandler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerDutyRosterRoutes(v1 fiber.Router, rosterHandler *handlers.DutyRosterHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
dutyRosters := v1.Group("/duty-roster", authMiddleware)
dutyRosters.Post("/create", middleware.PermissionRequired(authSvc, "duty_roster.create"), rosterHandler.CreateAny)
dutyRosters.Get("/get-all", middleware.PermissionRequired(authSvc, "duty_roster.read"), rosterHandler.GetAllRosters)
dutyRosters.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "duty_roster.read"), rosterHandler.ListAnyDatatable)
dutyRosters.Patch("/update", middleware.PermissionRequired(authSvc, "duty_roster.update"), rosterHandler.UpdateAny)
dutyRosters.Delete("/delete", middleware.PermissionRequired(authSvc, "duty_roster.delete"), rosterHandler.DeleteAny)
dutyRosters.Delete("/crew", middleware.PermissionRequired(authSvc, "duty_roster.delete"), rosterHandler.DeleteAnyCrew)
}

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerEASAReleaseRoutes(v1 fiber.Router, h *handlers.EASAReleaseHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/easa-releases", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "easa_release.create"), h.Create)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.ListByHelicopter)
g.Get("/get-by-flight/:flight_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByFlight)
g.Get("/get-by-complaint/:complaint_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByComplaint)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByID)
g.Patch("/update/:id", middleware.PermissionRequired(authSvc, "easa_release.update"), h.Update)
g.Post("/sign/:id", middleware.PermissionRequired(authSvc, "easa_release.sign"), h.Sign)
g.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "easa_release.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFacilityRoutes(v1 fiber.Router, facilityHandler *handlers.FacilityHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
facilities := v1.Group("/facilities", authMiddleware)
facilities.Post("/create", middleware.PermissionRequired(authSvc, "facility.create"), facilityHandler.Create)
facilities.Get("/get-all", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.List)
facilities.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.ListDatatable)
facilities.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.Get)
facilities.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "facility.update"), facilityHandler.Update)
facilities.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "facility.delete"), facilityHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFederalStateRoutes(v1 fiber.Router, federalStateHandler *handlers.FederalStateHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
federalState := v1.Group("/federal-state", authMiddleware)
federalState.Post("/create", middleware.PermissionRequired(authSvc, "federal_state.create"), federalStateHandler.Create)
federalState.Get("/get-all", middleware.PermissionRequired(authSvc, "federal_state.read"), federalStateHandler.List)
federalState.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "federal_state.read"), federalStateHandler.ListDatatable)
federalState.Patch("/update/:id", middleware.PermissionRequired(authSvc, "federal_state.update"), federalStateHandler.Update)
federalState.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "federal_state.delete"), federalStateHandler.Delete)
}

View File

@@ -0,0 +1,62 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFileManagerRoutes(
v1 fiber.Router,
folderHandler *handlers.FileManagerFolderHandler,
fileHandler *handlers.FileManagerFileHandler,
attachmentHandler *handlers.FileManagerAttachmentHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
fm := v1.Group("/file-manager", authMiddleware)
fm.Get("/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.ListAll)
fm.Get("/events", middleware.PermissionRequired(authSvc, "file_manager.read"), eventsHandler.Stream)
fm.Get("/trash/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.ListTrashItems)
fm.Patch("/move", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.MoveNodes)
fm.Delete("/delete", middleware.PermissionRequired(authSvc, "file_manager.delete"), folderHandler.DeleteNodesBulk)
fm.Patch("/restore", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.RestoreNodesBulk)
fm.Delete("/purge-delete", middleware.PermissionRequired(authSvc, "file_manager.delete"), folderHandler.PurgeNodesBulk)
folders := fm.Group("/folders")
folders.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), folderHandler.CreateFolder)
folders.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.GetFolder)
folders.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.UpdateFolder)
files := fm.Group("/files")
files.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFile)
files.Post("/create-from-template", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFromTemplate)
files.Post("/create-aircraft-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateAircraftImage)
files.Post("/create-base-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateBaseImage)
files.Post("/create-dul-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateDULImage)
files.Post("/create-user-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateUserImage)
files.Post("/create-takeover-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateTakeoverFile)
files.Get("/:fileId/edit-session", middleware.PermissionRequired(authSvc, "file_manager.read"), fileHandler.EditSession)
files.Post("/create-mission-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateMissionFile)
files.Post("/upload", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadFile)
files.Post("/upload-aircraft-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadAircraftImage)
files.Post("/upload-base-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadBaseImage)
files.Post("/upload-dul-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadDULImage)
files.Post("/upload-user-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadUserImage)
files.Post("/upload-takeover-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadTakeoverFile)
files.Post("/upload-mission-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadMissionFile)
files.Post("/upload-fleet-status-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadFleetStatusFile)
files.Post("/create-fleet-status-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFleetStatusFile)
files.Delete("/cancel-upload", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CancelUpload)
files.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), fileHandler.GetFile)
files.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "file_manager.update"), fileHandler.UpdateFile)
attachments := fm.Group("/attachments")
attachments.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), attachmentHandler.CreateAttachment)
attachments.Get("/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), attachmentHandler.ListAttachments)
attachments.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), attachmentHandler.GetAttachment)
attachments.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "file_manager.delete"), attachmentHandler.DeleteAttachment)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFleetStatusRoutes(v1 fiber.Router, h *handlers.FleetStatusHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/fleet-status", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), h.Create)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "flight.read"), h.GetByID)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "flight.read"), h.GetByHelicopterID)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), h.ListDatatable)
g.Get("/history/:helicopter_id", middleware.PermissionRequired(authSvc, "flight.read"), h.ListHistoryByHelicopter)
g.Post("/mark-serviced/:id", middleware.PermissionRequired(authSvc, "flight.update"), h.MarkServiced)
g.Patch("/update/:id", middleware.PermissionRequired(authSvc, "flight.update"), h.Update)
g.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "flight.delete"), h.Delete)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFlightRoutes(v1 fiber.Router, flightHandler *handlers.FlightHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
flights := v1.Group("/flights", authMiddleware)
flights.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightHandler.List)
flights.Get("/get", middleware.PermissionRequired(authSvc, "flight.read"), flightHandler.GetByAuthUser)
}

View File

@@ -0,0 +1,24 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFlightDataRoutes(v1 fiber.Router, flightDataHandler *handlers.FlightDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
flightData := v1.Group("/flight-data", authMiddleware)
flightData.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), flightDataHandler.Create)
flightData.Get("/get/:id", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.Get)
flightData.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.Get)
flightData.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.List)
flightData.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.ListDatatable)
flightData.Patch("/update/:id", middleware.PermissionRequired(authSvc, "flight.update"), flightDataHandler.Update)
flightData.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "flight.delete"), flightDataHandler.Delete)
flightsData := v1.Group("/flights-data", authMiddleware)
flightsData.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.List)
flightsData.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.ListDatatable)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFMReportRoutes(v1 fiber.Router, fmReportHandler *handlers.FMReportHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
fmReports := v1.Group("/fm-reports", authMiddleware)
fmReports.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), fmReportHandler.List)
fmReports.Get("/get/:flight_id", middleware.PermissionRequired(authSvc, "flight.read"), fmReportHandler.GetByFlightID)
fmReports.Patch("/update/:flight_id", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.UpdateByFlightID)
fmReports.Post("/complete/:flight_id", middleware.PermissionRequired(authSvc, "flight.complete"), fmReportHandler.Complete)
fmReports.Post("/:id/attachments/upload-intent", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.RequestAttachmentUploadIntent)
fmReports.Post("/:id/attachments", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.UploadAttachment)
fmReports.Delete("/:id/attachments/:att_id", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.DeleteAttachment)
fmReports.Delete("/:id", middleware.PermissionRequired(authSvc, "flight.delete"), middleware.PinRequired(authSvc, sharedconst.PinActionFmReportDelete), fmReportHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerForcesPresentRoutes(v1 fiber.Router, forcesPresentHandler *handlers.ForcesPresentHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
forcesPresent := v1.Group("/forces-present", authMiddleware)
forcesPresent.Post("/create", middleware.PermissionRequired(authSvc, "forces_present.create"), forcesPresentHandler.Create)
forcesPresent.Get("/get-all", middleware.PermissionRequired(authSvc, "forces_present.read"), forcesPresentHandler.List)
forcesPresent.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "forces_present.read"), forcesPresentHandler.ListDatatable)
forcesPresent.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "forces_present.update"), forcesPresentHandler.Update)
forcesPresent.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "forces_present.delete"), forcesPresentHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHealthInsuranceCompaniesRoutes(v1 fiber.Router, healthInsuranceCompaniesHandler *handlers.HealthInsuranceCompaniesHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
hic := v1.Group("/health-insurance-companies", authMiddleware)
hic.Post("/create", middleware.PermissionRequired(authSvc, "health_insurance_companies.create"), healthInsuranceCompaniesHandler.Create)
hic.Get("/get-all", middleware.PermissionRequired(authSvc, "health_insurance_companies.read"), healthInsuranceCompaniesHandler.List)
hic.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "health_insurance_companies.read"), healthInsuranceCompaniesHandler.ListDatatable)
hic.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "health_insurance_companies.update"), healthInsuranceCompaniesHandler.Update)
hic.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "health_insurance_companies.delete"), healthInsuranceCompaniesHandler.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterRoutes(v1 fiber.Router, helicopterHandler *handlers.HelicopterHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
helicopters := v1.Group("/helicopters", authMiddleware)
helicopters.Post("/create", middleware.PermissionRequired(authSvc, "helicopter.create"), helicopterHandler.Create)
helicopters.Post("/:id/reports/next-number", middleware.PermissionRequired(authSvc, "helicopter.report.generate"), helicopterHandler.NextReportNumber)
helicopters.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.List)
helicopters.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.ListDatatable)
helicopters.Get("/get/:id", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.Get)
helicopters.Patch("/update/:id", middleware.PermissionRequired(authSvc, "helicopter.update"), helicopterHandler.Update)
helicopters.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "helicopter.delete"), helicopterHandler.Delete)
}

View File

@@ -0,0 +1,27 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterFileRoutes(
v1 fiber.Router,
handler *handlers.HelicopterFileHandler,
eventsHandler *handlers.HelicopterFileEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
files := v1.Group("/helicopter-files", authMiddleware)
files.Get("/events", middleware.PermissionRequired(authSvc, "helicopter.read"), eventsHandler.Stream)
files.Post("/upload", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Upload)
files.Post("/cancel-upload", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.CancelUpload)
files.Post("/create", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Create)
files.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter.read"), handler.List)
files.Get("/get/:helicopter_id", middleware.PermissionRequired(authSvc, "helicopter.read"), handler.Get)
files.Patch("/update", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Update)
files.Delete("/delete", middleware.PermissionRequired(authSvc, "helicopter.delete"), handler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterUsageRoutes(v1 fiber.Router, helicopterUsageHandler *handlers.HelicopterUsageHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
helicopterUsage := v1.Group("/helicopter-usage", authMiddleware)
helicopterUsage.Post("/create", middleware.PermissionRequired(authSvc, "helicopter_usage.create"), helicopterUsageHandler.Create)
helicopterUsage.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.List)
helicopterUsage.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.ListDatatable)
helicopterUsage.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.Get)
helicopterUsage.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.update"), helicopterUsageHandler.Update)
helicopterUsage.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.delete"), helicopterUsageHandler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationRoutes(v1 fiber.Router, h *handlers.HEMSOperationHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operation", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "hems_operation.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationCategoryRoutes(v1 fiber.Router, h *handlers.HEMSOperationCategoryHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operation-category", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "operation_category.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "operation_category.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "operation_category.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "operation_category.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "operation_category.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "operation_category.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationalDataRoutes(v1 fiber.Router, h *handlers.HEMSOperationalDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operational-data", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "operation.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "operation.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "operation.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "operation.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "operation.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "operation.delete"), h.Delete)
}

Some files were not shown because too many files have changed in this diff Show More