init push

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

423
docs/API_GUIDE.md Normal file
View File

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

96
docs/AUTH_SSO_FLOW.md Normal file
View File

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

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

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

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

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

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

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

View File

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

View File

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

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

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

129
docs/contacts_module.md Normal file
View File

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

View File

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

View File

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

38390
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

17
docs/docs_test.go Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

38365
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

26515
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

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