# 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.
