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