think
16px
820px

OnlyOffice-authored Correspondence letters — design spec

Status: APPROVED DESIGN (brainstormed 2026-07-24; decisions confirmed by user). Next step: implementation plan.

Directive (user): When the office module is enabled, Correspondence drafts/manages/renders letters through OnlyOffice's editor (docx), defaulting to docx authoring with a "quick text" escape hatch. Letterhead header/footer must be set up through OnlyOffice too (admins edit the letterhead template in the Word editor, native header/footer sections). When the module is off, the existing plain-text→HTML + Gotenberg path keeps working.

Corrections to the earlier queued stub (verified against code)

  1. No number stamping exists today. AssignNumberWithLetterhead (correspondence/app/service.go:389) concatenates header_html + body_html + footer_html and renders via Gotenberg Chromium — there is no {{number}}/{{date}} substitution anywhere in the pipeline. Today's rendered PDF contains the assigned number only if the author typed it. The docx path FIXES this via merge fields.
  2. Letter compose is a plain Carbon TextArea (NewLetterModal.tsx) wrapped to HTML client-side (htmlFromPlainText), not TipTap. Drafts are write-once — no update endpoint for body/subject exists; a rejected letter cannot be revised or resubmitted (dead-end state).
  3. Letters have zero DMS linkage. The rendered PDF is a content-addressed blob referenced by letters.content_hash; letters never become documents. The office machinery (handlers_office.go) is hard-wired to document IDs: token claims {purpose, doc, version, uid, exp}, all storage calls go through s.dms.*.

Decisions (user-confirmed)

  • D1 — Default docx, quick-text escape. With officeEditEnabled(), the create modal defaults to docx authoring; a secondary "quick text" option keeps the plain TextArea → body_html path. Office off ⇒ quick text only (today's behavior).
  • D2 — Letterheads are OnlyOffice-edited docx templates. letterhead_templates gains an optional docx artifact; admins edit it directly in OnlyOffice (kop in the native Word header section, footer likewise). Shipped starter templates have header/footer sections pre-wired. Upload/download of the .docx also supported. The legacy header_html/footer_html fields stay (used by the HTML letter path and document finalize).
  • D3 — Merge fields v1: {{NOMOR}}, {{TANGGAL}} (assignment date, Indonesian long form, e.g. 24 Juli 2026), {{PERIHAL}} (subject), {{SIFAT}} (classification). No {{LAMPIRAN}} — attachments can change after numbering, a stamped count would go stale.
  • D4 — Editability: a docx letter is editable in OnlyOffice while draft or rejected, by the creator or correspondence.admin; view-only in in_review/approved (approvers must see exactly what they approve) and forever after numbered. Consequence: submit-for-approval is additionally allowed from rejected (one new transition; today submit is draft-only), otherwise "rejected is editable" has no outlet.

Architecture

Letter-owned docx + subject-aware office endpoints. Considered and REJECTED: backing each letter with a hidden DMS document (hidden docs would leak into every document surface — lists, search, gapless doc-ID sequence, retention, reports — and letter access rules (mayReadLetter) don't map onto document ACLs). Instead the letter (and the letterhead template) own a docx blob in the existing kernel.BlobStore, and the office HTTP layer learns two more subjects.

Data model — migration 00128 (renumber if a co-agent takes 00128 first)

ALTER TABLE letters
  ADD COLUMN authoring text NOT NULL DEFAULT 'html' CHECK (authoring IN ('html','docx')),
  ADD COLUMN draft_docx_hash text NULL,      -- current editable docx (blob key)
  ADD COLUMN draft_docx_rev  integer NOT NULL DEFAULT 0,  -- bumps on every OnlyOffice save; feeds editor cache key
  ADD COLUMN final_docx_hash text NULL,      -- merged (placeholder-substituted) docx frozen at numbering
  ADD COLUMN letterhead_id   uuid NULL REFERENCES letterhead_templates(id) ON DELETE SET NULL;

ALTER TABLE letterhead_templates
  ADD COLUMN docx_hash text NULL,            -- the docx template (blob key)
  ADD COLUMN docx_rev  integer NOT NULL DEFAULT 0;

Existing letters remain authoring='html' and behave exactly as today.

Office layer generalization (handlers_office.go)

  • The HS256 office token gains a sub claim: document (default when absent — backward compatible with in-flight tokens), letter, letterhead, plus a variant for the letter-final case (below).
  • OfficeContent / OfficeCallback branch on sub:
  • documents.dms.* exactly as today (zero behavior change).
  • letter → correspondence service: OpenDraftDocx(letterID) streams the blob at draft_docx_hash; callback → SaveDraftDocx(ctx, letterID, uid, bytes).
  • letter + variant=final → streams final_docx_hash (used only by the numbering conversion, read-only; never gets a callback token).
  • letterhead → letterhead service: OpenTemplateDocx(id) / SaveTemplateDocx(ctx, id, uid, bytes).
  • Callback hardening (F11 parity): callback tokens are minted only for editable sessions, AND the save handler re-checks editability at save time (letter still draft/rejected, saver still creator/admin; letterhead saver still holds letterhead.manage) — mirrors the document path's EditingLocked re-check. A letter numbered while an editor was open ⇒ the late save is rejected.
  • Save semantics: blob Put → update draft_docx_hash/docx_hash + rev+1 + updated_at. Only OnlyOffice callback status == 2 (all editors closed) persists, same as documents. Editor cache keys: letter_{id}_{rev}, letterhead_{id}_{rev} (sanitized, < 128 chars).

New endpoints

Endpoint Auth Purpose
GET /api/v1/letters/{id}/office-config correspondence.read + mayReadLetter Editor config for a docx letter. editable per D4; callback token only when editable.
GET /api/v1/letterheads/{id}/office-config letterhead.manage Editor config for a letterhead template. Lazy-seeds the starter docx (blob put + row update) when docx_hash is NULL, so "Edit in OnlyOffice" always works. Always editable.
POST /api/v1/letterheads/{id}/docx letterhead.manage Multipart upload replacing the docx template (rev+1). Validated: zip opens, [Content_Types].xml + word/document.xml present, size cap 20 MB.
GET /api/v1/letterheads/{id}/docx letterhead.manage Download the current template.
POST /api/v1/letters (extended) unchanged Accepts authoring (html default | docx) and letterhead_id. docx requires officeEditEnabled(); server seeds draft_docx_hash (see creation flow).

GET /letters/{id} response additionally returns authoring and letterhead_id; GET /letterheads list items gain has_docx so the create modal can filter/flag docx-ready letterheads. Office content/callback stay on the existing public token routes.

Flows

Create (docx): POST /letters {authoring:"docx", letterhead_id?} → seed draft_docx_hash:
- letterhead chosen and it has a docx template → draft_docx_hash = letterhead.docx_hash (content-addressed ⇒ zero-copy; first save diverges naturally);
- letterhead chosen but no docx template → 422 correspondence.letterhead_no_docx (frontend prevents this by filtering on has_docx);
- no letterhead → seed from the embedded blank letter starter.
Frontend then navigates straight into the editor.

Edit: /letters/{id}/edit route mounts the OnlyOffice editor (same DocsAPI mount pattern as OfficeEditorView), config from the letter office-config endpoint. View mode doubles as the previewer for approvers and post-approval states — no separate preview pipeline.

Submit / approve: unchanged, except submit now allowed from rejected as well as draft (both authoring modes, uniformly).

Assign number (docx letter) — mirrors today's three-phase gapless design (service.go:362-378):
1. Tx A (unchanged): advisory scheme lock → NextSeqForPeriod → reserved allocation. Number string computable immediately via domain.FormatNumber.
2. Out of tx: load draft docx → merge-replace {{NOMOR}} {{TANGGAL}} {{PERIHAL}} {{SIFAT}} across word/document.xml + word/header*.xml + word/footer*.xml → blob-put merged docx and persist final_docx_hash (small standalone update, needed so the doc-server can fetch it) → convert to PDF:
- primary: OnlyOffice ConvertService — the core of convertViaOnlyOffice (handlers_publish.go:151) is extracted into a URL-based helper; the letter path feeds it a sub=letter, variant=final content-token URL and a per-attempt unique key;
- fallback: Gotenberg LibreOffice (s.office.ToPDF("letter.docx", merged)) — same fallback idiom as publish/preview. Consequence: docx letters remain numberable even if the office license lapses; only editing needs the module.
→ blob-put the PDF.
3. Tx B (unchanged): MarkAllocationAssigned + UpdateLetterNumbered. Any failure in phase 2 voids the allocation exactly like today's render-fail path (and clears final_docx_hash).
The AssignNumber modal for docx letters shows scheme only (letterhead was chosen at creation; no HTML injection happens). HTML letters keep the letterhead dropdown and the existing render path verbatim.

Letterhead admin: per-template actions — "Edit in OnlyOffice" (→ /letterheads/{id}/edit, lazy-seeded starter), "Upload .docx", "Download .docx". Helper text documents the four merge fields. The HTML header/footer editors remain for the HTML path.

Merge-field replacer (the one new algorithmic piece)

Pure Go, no new dependencies. Word splits literal text across arbitrary <w:r> runs (rsid/spellcheck), so naive string replace misses placeholders. Algorithm, per <w:p> paragraph in each target part: concatenate the paragraph's <w:t> texts → locate {{FIELD}} spans in the joined string → rewrite the runs so the replacement lands in the first affected run and the remainder of the span is emptied (preserving run properties/formatting of the first run). Substituted values are XML-escaped (subjects can contain <, &). Unknown/absent placeholders are left untouched; a letter with no placeholders renders fine (parity with today, which stamps nothing). Operates via archive/zip rewrite of the three part globs; all other zip entries are copied byte-for-byte. Corrupt output is caught by the convert step and voids the allocation — same failure envelope as a Gotenberg render error today.

Embedded starter assets (go:embed)

Two small docx files checked into the repo (built once with python-docx or handcrafted OOXML; they are plain zips):
- Letterhead starter: header section with sample kop text, footer section with sample footer, body containing the standard tata-naskah opening block (Nomor: {{NOMOR}}, Sifat: {{SIFAT}}, Perihal: {{PERIHAL}}, {{TANGGAL}}) as editable guidance. Guarantees header/footer sections exist so OnlyOffice's header/footer editing is immediately discoverable (D2).
- Blank letter starter: A4, empty header/footer sections wired, same opening block in the body (author can delete it).
No hard validation that uploaded templates contain header/footer parts (some layouts legitimately put the kop in the body); OnlyOffice can insert headers into any docx regardless.

Frontend

  • NewLetterModal: when me.officeEdit — mode toggle defaulting to "Word editor (docx)" with fields type/classification/subject + letterhead dropdown (docx-ready ones flagged; "no letterhead" allowed), secondary "Quick text" restoring the TextArea. On docx create → navigate to /letters/{id}/edit. Without officeEdit, exactly today's modal.
  • New routes (standalone, outside AppShell, mirroring /documents/d/:docId/office): /letters/:id/edit and /letterheads/:id/edit — one shared letter/letterhead office-editor view parameterized by config endpoint, title, and back-nav.
  • LetterDetailModal: for docx letters the srcDoc iframe is replaced by an "Open letter" button (label Edit/View per status); Download unchanged for numbered letters. AssignNumberModal hides the letterhead dropdown when authoring === 'docx'.
  • Letterhead admin page: docx template controls per D2.
  • API layer: react-query hooks in web/src/api/correspondence.ts (existing req() raw-fetch pattern) + a letterhead office-config hook mirroring web/src/api/office.ts. i18n in the correspondence feature slice + core office strings, en + id.

Security notes

  • Token scoping: sub claim prevents cross-subject replay (a letter content token cannot fetch a document, and vice versa); variant=final tokens are read-only by construction (no callback purpose ever minted for them). Claims stay {purpose, sub, doc, version/rev, uid, variant?, exp} — no blob hashes in tokens.
  • mayReadLetter gates letter office-config (confidential letters stay restricted); letterhead.manage gates everything letterhead; save-time re-checks per F11 parity (above).
  • Merge substitution XML-escapes values — no template/XML injection via subject or classification.
  • Upload validation (zip structure + size cap) on letterhead docx; SSRF posture unchanged (ConvertService fetches only our signed content URLs; Gotenberg deny-list from 686626a already covers the LibreOffice route).
  • Flagged, out of scope (user radar): letter PDFs (GET /letters/{id}/content) and letter attachments are served with no egress watermarking, unlike every document path.

Non-goals

  • No migration of existing HTML letters; no metadata (subject/classification) editing; inbound intake untouched; approval/disposisi semantics unchanged (except the one submit-from-rejected transition); Gotenberg stays (HTML letters, office-off deployments, convert fallback, AI export); no letter egress watermarking (flagged above); no letter draft version history (single current draft + frozen final docx is the model — the numbered PDF is the immutable artifact).

Verification sketch (details in the plan)

Builds (go build ./... && go vet ./..., tsc && vite build); live e2e on the x056 demo (office module is on): lazy-seed a letterhead starter → edit kop in OnlyOffice → save; create docx letter from it → edit body → submit → approve → assign number → download PDF and assert the assigned number string appears in extracted text (pdfium via the stego venv); quick-text letter regression (create + number via HTML path); callback re-check (attempt save after numbering ⇒ rejected); letterhead upload validation rejects a non-zip.