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

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.