init push
This commit is contained in:
52
.air.toml
Normal file
52
.air.toml
Normal 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
33
.dockerignore
Normal 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
248
.env.local.example
Normal 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
62
.gitignore
vendored
Normal 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
16
.releaserc
Normal 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
109
Dockerfile
Normal 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
107
Makefile
Normal 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
435
README.md
Normal 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
|
||||
```
|
||||
392
cmd/api/main.go
Normal file
392
cmd/api/main.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"wucher/internal/app/api"
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/service"
|
||||
|
||||
_ "wucher/docs"
|
||||
)
|
||||
|
||||
var (
|
||||
apiRunFn = run
|
||||
apiFatalFn = log.Fatal
|
||||
)
|
||||
|
||||
func configFilePath() string {
|
||||
if v := os.Getenv("CONFIG_FILE"); v != "" {
|
||||
return v
|
||||
}
|
||||
basePath := os.Getenv("CONFIG_BASE_PATH")
|
||||
if basePath == "" {
|
||||
basePath = "/tmp"
|
||||
}
|
||||
filename := os.Getenv("CONFIG_FILENAME")
|
||||
if filename == "" {
|
||||
filename = "config.generated.json"
|
||||
}
|
||||
return basePath + "/" + filename
|
||||
}
|
||||
|
||||
type apiServer interface {
|
||||
Start(addr string) error
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
// @title Wucher API
|
||||
// @version 1.0
|
||||
// @description Wucher backend API (JSON:API).
|
||||
// @description
|
||||
// @description Auth Flow (Email/Password):
|
||||
// @description 1) POST /api/v1/auth/register
|
||||
// @description 2) Check email, open verify link (GET /api/v1/auth/verify-email)
|
||||
// @description 3) POST /api/v1/auth/login
|
||||
// @description - If TOTP enabled: response 202 with challenge_token
|
||||
// @description 4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies
|
||||
// @description
|
||||
// @description Auth Flow (TOTP setup):
|
||||
// @description 1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)
|
||||
// @description 2) POST /api/v1/auth/totp/confirm -> enabled=true
|
||||
// @description
|
||||
// @description Auth Flow (Microsoft Entra SSO):
|
||||
// @description 1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft
|
||||
// @description 2) Microsoft redirects to /api/v1/auth/microsoft/callback
|
||||
// @description 3) Backend checks existing SSO mapping (no auto-register)
|
||||
// @description 4) If linked user exists -> set cookies + optional redirect to AUTH_SSO_SUCCESS_REDIRECT
|
||||
// @description 5) If mapping not linked -> login rejected (401)
|
||||
// @description 6) Existing authenticated user can link/unlink via /api/v1/auth/sso/link and /api/v1/auth/sso/unlink
|
||||
// @tag.name Complaints - Sign Off
|
||||
// @tag.description Corrective-action sign-off for a helicopter. After a complaint's action_taken is recorded, it is signed off here (POST /action-signoffs/sign with complaint_id); a fleet-wide sign-off (no complaint_id) is also supported. A signed sign-off is the prerequisite for creating an EASA maintenance release.
|
||||
// @BasePath /
|
||||
func main() {
|
||||
if err := apiRunFn(); err != nil {
|
||||
apiFatalFn(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
appCfg, err := config.LoadFromFile(configFilePath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return runWithContext(ctx, appCfg)
|
||||
}
|
||||
|
||||
func runWithContext(ctx context.Context, appCfg config.AppConfig) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
db, err := initializeDatabase(appCfg.MySQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sqlDB, sqlErr := db.DB(); sqlErr == nil {
|
||||
defer sqlDB.Close()
|
||||
}
|
||||
|
||||
if err := bootstrapData(db); err != nil {
|
||||
return err
|
||||
}
|
||||
repos, err := initializeRepositories(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
infra, err := initializeInfrastructure(ctx, appCfg, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
services, err := initializeServices(appCfg, repos, infra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := services.MasterSettings.SeedMicrosoftEntraConfigIfMissing(ctx, appCfg.MicrosoftSSO); err != nil {
|
||||
return fmt.Errorf("seed microsoft entra master settings: %w", err)
|
||||
}
|
||||
handlersSet, err := initializeHandlers(services, infra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initializeRealtimeComponents(ctx, repos, services, infra); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app, addr, err := buildHTTPServer(appCfg, services, handlersSet, infra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := api.NewServer(app, api.WithShutdownTimeout(appCfg.HTTP.ShutdownTimeout))
|
||||
|
||||
infra.Logger.Info("api listening",
|
||||
"addr", addr,
|
||||
"queue_backend", "sqs",
|
||||
"queue_outbox_enabled", appCfg.Queue.OutboxEnabled,
|
||||
)
|
||||
if err := serveAPI(ctx, srv, addr); err != nil {
|
||||
return err
|
||||
}
|
||||
infra.Logger.Info("api stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveAPI(ctx context.Context, srv apiServer, addr string) error {
|
||||
if srv == nil {
|
||||
return errors.New("api server is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
serverErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrCh <- srv.Start(addr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErrCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
shutdownErr := srv.Shutdown(context.Background())
|
||||
serverErr := <-serverErrCh
|
||||
if shutdownErr != nil {
|
||||
return shutdownErr
|
||||
}
|
||||
return serverErr
|
||||
}
|
||||
|
||||
func startFileManagerAutoPurgeLoop(
|
||||
ctx context.Context,
|
||||
fileManagerSvc *service.FileManagerService,
|
||||
trashCfg config.FileManagerTrashPolicyConfig,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
if fileManagerSvc == nil || !trashCfg.AutoPurgeEnabled {
|
||||
return
|
||||
}
|
||||
interval := trashCfg.AutoPurgeInterval
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
batchSize := trashCfg.AutoPurgeBatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 200
|
||||
}
|
||||
|
||||
runOnce := func() {
|
||||
purgedFiles, purgedFolders, err := fileManagerSvc.PurgeExpiredTrash(ctx, time.Now().UTC(), batchSize)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Error("file manager auto-purge run failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logger != nil && (purgedFiles > 0 || purgedFolders > 0) {
|
||||
logger.Info(
|
||||
"file manager auto-purge completed",
|
||||
"purged_files", purgedFiles,
|
||||
"purged_folders", purgedFolders,
|
||||
"batch_size", batchSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
runOnce()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startFleetStatusAOGRecomputeLoop(
|
||||
ctx context.Context,
|
||||
fleetStatusSvc *service.FleetStatusService,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
if fleetStatusSvc == nil {
|
||||
return
|
||||
}
|
||||
// TESTING ONLY: sweep tiap menit supaya kolom AOG + audit nyusul cepat dengan
|
||||
// MEL grace yang sementara dihitung dalam menit.
|
||||
// TODO: kembalikan ke time.Hour sebelum production.
|
||||
interval := time.Minute
|
||||
runOnce := func() {
|
||||
n, err := fleetStatusSvc.RecalculateAllAOG(ctx)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Error("fleet status AOG recompute failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("fleet status AOG recompute completed", "helicopters", n)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
runOnce()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startFileManagerLifecycleCleanupLoop(
|
||||
ctx context.Context,
|
||||
fileManagerSvc *service.FileManagerService,
|
||||
lifecycleCfg config.FileManagerLifecycleConfig,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
if fileManagerSvc == nil || !lifecycleCfg.EnableCleanupJob {
|
||||
return
|
||||
}
|
||||
interval := time.Hour
|
||||
batchSize := lifecycleCfg.CleanupBatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
grace := time.Duration(lifecycleCfg.OrphanGraceDays) * 24 * time.Hour
|
||||
tempGrace := time.Duration(lifecycleCfg.TempUploadGraceHours) * time.Hour
|
||||
dryRun := lifecycleCfg.CleanupDryRun
|
||||
|
||||
runOnce := func() {
|
||||
stats, err := fileManagerSvc.CleanupOrphanPendingFiles(ctx, time.Now().UTC(), grace, batchSize, dryRun)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Error("file manager lifecycle cleanup failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("file manager lifecycle cleanup run completed",
|
||||
"scanned", stats.Scanned,
|
||||
"deleted", stats.Deleted,
|
||||
"skipped", stats.Skipped,
|
||||
"failed", stats.Failed,
|
||||
"dry_run", stats.DryRun,
|
||||
"batch_size", batchSize,
|
||||
)
|
||||
}
|
||||
|
||||
tempStats, err := fileManagerSvc.CleanupStaleTempUploads(ctx, time.Now().UTC(), tempGrace, batchSize, dryRun)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Error("file manager temp upload cleanup failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("file manager temp upload cleanup run completed",
|
||||
"scanned", tempStats.Scanned,
|
||||
"deleted", tempStats.Deleted,
|
||||
"skipped", tempStats.Skipped,
|
||||
"failed", tempStats.Failed,
|
||||
"dry_run", tempStats.DryRun,
|
||||
"batch_size", batchSize,
|
||||
)
|
||||
}
|
||||
|
||||
draftStats, err := fileManagerSvc.CleanupStaleDraftFiles(ctx, time.Now().UTC(), tempGrace, batchSize, dryRun)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Error("file manager draft cleanup failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("file manager draft cleanup run completed",
|
||||
"scanned", draftStats.Scanned,
|
||||
"deleted", draftStats.Deleted,
|
||||
"skipped", draftStats.Skipped,
|
||||
"failed", draftStats.Failed,
|
||||
"dry_run", draftStats.DryRun,
|
||||
"batch_size", batchSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
runOnce()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startHelicopterFileAttachmentReconcileLoop(
|
||||
ctx context.Context,
|
||||
helicopterFileSvc *service.HelicopterFileService,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
if helicopterFileSvc == nil {
|
||||
return
|
||||
}
|
||||
interval := 2 * time.Second
|
||||
batchSize := 50
|
||||
|
||||
runOnce := func() int {
|
||||
finalized, err := helicopterFileSvc.FinalizePendingAttachments(ctx, batchSize)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Error("helicopter file attachment reconcile failed", "error", err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if logger != nil && finalized > 0 {
|
||||
logger.Info("helicopter file attachment reconcile completed", "finalized", finalized, "batch_size", batchSize)
|
||||
}
|
||||
return finalized
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if finalized := runOnce(); finalized > 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
153
cmd/api/main_test.go
Normal file
153
cmd/api/main_test.go
Normal 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
834
cmd/api/startup.go
Normal 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
|
||||
}
|
||||
129
cmd/file-lifecycle-backfill/main.go
Normal file
129
cmd/file-lifecycle-backfill/main.go
Normal 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
79
cmd/healthcheck/main.go
Normal 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
83
cmd/worker/file_worker.go
Normal 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
99
cmd/worker/mail_worker.go
Normal 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
80
cmd/worker/main.go
Normal 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
136
cmd/worker/main_test.go
Normal 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
310
config/local/config.json
Normal 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
307
config/prod/config.json
Normal 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
307
config/staging/config.json
Normal 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
2
configs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
saml.key
|
||||
metadata.xml
|
||||
19
configs/saml.crt
Normal file
19
configs/saml.crt
Normal 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
5165
coverage.html
Normal file
File diff suppressed because it is too large
Load Diff
114
docker-compose.dev.yml
Normal file
114
docker-compose.dev.yml
Normal 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
110
docker-compose.staging.yml
Normal 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
110
docker-compose.test.yml
Normal 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
86
docker-compose.yml
Normal 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
423
docs/API_GUIDE.md
Normal 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
96
docs/AUTH_SSO_FLOW.md
Normal 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
379
docs/aog-mcf-nsr-flow.md
Normal 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. Tanya–Jawab 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
49
docs/audit-log.md
Normal 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
272
docs/auth-flow.md
Normal 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`.
|
||||
146
docs/base-contact-roles-frontend-flow.md
Normal file
146
docs/base-contact-roles-frontend-flow.md
Normal 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.
|
||||
980
docs/collections/Wucher.json
Normal file
980
docs/collections/Wucher.json
Normal 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
359
docs/contact-create-flow.md
Normal 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
129
docs/contacts_module.md
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
187
docs/data-migration-wiki-ckeditor.html
Normal file
187
docs/data-migration-wiki-ckeditor.html
Normal 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>
|
||||
94
docs/data-migration-wiki.md
Normal file
94
docs/data-migration-wiki.md
Normal 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
38390
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
17
docs/docs_test.go
Normal file
17
docs/docs_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
74
docs/duty-roster-migrations.md
Normal file
74
docs/duty-roster-migrations.md
Normal 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
735
docs/error-catalog.md
Normal 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.
|
||||
124
docs/file-lifecycle-s3-cleanup.md
Normal file
124
docs/file-lifecycle-s3-cleanup.md
Normal 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.
|
||||
143
docs/file-manager-aircraft-upload-create.md
Normal file
143
docs/file-manager-aircraft-upload-create.md
Normal 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
|
||||
73
docs/file-manager-attachment.md
Normal file
73
docs/file-manager-attachment.md
Normal 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)
|
||||
62
docs/file-manager-base-upload-create.md
Normal file
62
docs/file-manager-base-upload-create.md
Normal 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
|
||||
829
docs/file-manager-collabora-wopi-flow.md
Normal file
829
docs/file-manager-collabora-wopi-flow.md
Normal 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.
|
||||
241
docs/file-manager-delete-store.md
Normal file
241
docs/file-manager-delete-store.md
Normal 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
230
docs/file-manager-get.md
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
207
docs/file-manager-preview.md
Normal file
207
docs/file-manager-preview.md
Normal 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` |
|
||||
80
docs/file-manager-update-move.md
Normal file
80
docs/file-manager-update-move.md
Normal 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
288
docs/file-manager-upload.md
Normal 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
|
||||
}
|
||||
}
|
||||
```
|
||||
260
docs/flight-ops-locks-and-usage-sync.md
Normal file
260
docs/flight-ops-locks-and-usage-sync.md
Normal 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}`
|
||||
121
docs/frontend-sso-exchange-flow.md
Normal file
121
docs/frontend-sso-exchange-flow.md
Normal 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
165
docs/mcf-flow.md
Normal 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
91
docs/password-reset.md
Normal 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
157
docs/queue-sqs-migration.md
Normal 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
349
docs/security-pin.md
Normal 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
38365
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
26515
docs/swagger.yaml
Normal file
26515
docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
483
docs/takeover-file-api.md
Normal file
483
docs/takeover-file-api.md
Normal 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`
|
||||
578
docs/takeover-step-by-step.md
Normal file
578
docs/takeover-step-by-step.md
Normal 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
104
go.mod
Normal 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
249
go.sum
Normal 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=
|
||||
219
internal/app/api/audit_middleware.go
Normal file
219
internal/app/api/audit_middleware.go
Normal 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
|
||||
}
|
||||
208
internal/app/api/audit_middleware_test.go
Normal file
208
internal/app/api/audit_middleware_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
internal/app/api/compress_wopi_test.go
Normal file
131
internal/app/api/compress_wopi_test.go
Normal 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()
|
||||
}
|
||||
471
internal/app/api/middleware.go
Normal file
471
internal/app/api/middleware.go
Normal 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
|
||||
}
|
||||
586
internal/app/api/middleware_test.go
Normal file
586
internal/app/api/middleware_test.go
Normal 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()
|
||||
}
|
||||
9
internal/app/api/pin_actions.go
Normal file
9
internal/app/api/pin_actions.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package api
|
||||
|
||||
type PinProtectedEndpoint struct {
|
||||
Method string
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
var PinProtectedEndpoints = []PinProtectedEndpoint{}
|
||||
64
internal/app/api/pin_actions_test.go
Normal file
64
internal/app/api/pin_actions_test.go
Normal 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
140
internal/app/api/routes.go
Normal 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)
|
||||
}
|
||||
15
internal/app/api/routes_action_signoff.go
Normal file
15
internal/app/api/routes_action_signoff.go
Normal 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)
|
||||
}
|
||||
23
internal/app/api/routes_air_rescuer_checklist.go
Normal file
23
internal/app/api/routes_air_rescuer_checklist.go
Normal 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)
|
||||
}
|
||||
14
internal/app/api/routes_audit.go
Normal file
14
internal/app/api/routes_audit.go
Normal 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)
|
||||
}
|
||||
142
internal/app/api/routes_auth.go
Normal file
142
internal/app/api/routes_auth.go
Normal 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",
|
||||
}},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
154
internal/app/api/routes_auth_test.go
Normal file
154
internal/app/api/routes_auth_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
35
internal/app/api/routes_base.go
Normal file
35
internal/app/api/routes_base.go
Normal 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,
|
||||
)
|
||||
}
|
||||
20
internal/app/api/routes_branding.go
Normal file
20
internal/app/api/routes_branding.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_complaint.go
Normal file
26
internal/app/api/routes_complaint.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_contacts.go
Normal file
26
internal/app/api/routes_contacts.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_dul.go
Normal file
26
internal/app/api/routes_dul.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_duty_roster.go
Normal file
19
internal/app/api/routes_duty_roster.go
Normal 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)
|
||||
}
|
||||
21
internal/app/api/routes_easa_release.go
Normal file
21
internal/app/api/routes_easa_release.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_facility.go
Normal file
19
internal/app/api/routes_facility.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_federal_state.go
Normal file
18
internal/app/api/routes_federal_state.go
Normal 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)
|
||||
}
|
||||
62
internal/app/api/routes_file_manager.go
Normal file
62
internal/app/api/routes_file_manager.go
Normal 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)
|
||||
}
|
||||
22
internal/app/api/routes_fleet_status.go
Normal file
22
internal/app/api/routes_fleet_status.go
Normal 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)
|
||||
}
|
||||
15
internal/app/api/routes_flight.go
Normal file
15
internal/app/api/routes_flight.go
Normal 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)
|
||||
}
|
||||
24
internal/app/api/routes_flight_data.go
Normal file
24
internal/app/api/routes_flight_data.go
Normal 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)
|
||||
}
|
||||
22
internal/app/api/routes_fm_report.go
Normal file
22
internal/app/api/routes_fm_report.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_forces_present.go
Normal file
18
internal/app/api/routes_forces_present.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_health_insurance_companies.go
Normal file
18
internal/app/api/routes_health_insurance_companies.go
Normal 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)
|
||||
}
|
||||
20
internal/app/api/routes_helicopter.go
Normal file
20
internal/app/api/routes_helicopter.go
Normal 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)
|
||||
}
|
||||
27
internal/app/api/routes_helicopter_file.go
Normal file
27
internal/app/api/routes_helicopter_file.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_helicopter_usage.go
Normal file
19
internal/app/api/routes_helicopter_usage.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_hems_operation.go
Normal file
19
internal/app/api/routes_hems_operation.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_hems_operation_category.go
Normal file
19
internal/app/api/routes_hems_operation_category.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_hems_operational_data.go
Normal file
19
internal/app/api/routes_hems_operational_data.go
Normal 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
Reference in New Issue
Block a user