Files
fm_be/docs/file-manager-collabora-wopi-flow.md
2026-07-16 22:16:45 +07:00

23 KiB

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:

{
  "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:

{
  "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:

{
  "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:

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

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.

// 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:

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

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

// 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

// 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

window.open(session.data.collabora_action, "_blank")

atau:

<iframe src={session.data.collabora_action} />

Kedua contoh di atas tidak mengirim access_token bootstrap.

Contoh yang benar

<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:

{
  "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:

"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:

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.