The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.
Backend additions:
- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
walker. Per the head's Slice B brief, scope is paragraphs +
bold/italic + blank-line spacing. Placeholders pass through
unchanged for the v1 substitution pass. CRLF normalisation; nested
formatting (***bold-italic***); two delimiter forms (* and _);
XML-escaping for &/</>; explicit empty-paragraph emit so blank
lines round-trip. 12 unit tests.
- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
service. Pipeline: ConvertDotmToDocx pre-pass → extract
word/document.xml → render each included section's content_md_<lang>
→ splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
the body → strip anchors for excluded sections → append unanchored
sections before <w:sectPr> → repack zip → run v1 placeholder pass.
RE2-friendly anchor scanner walks markers in body-order and matches
open/close pairs with a stack (handles unbalanced anchors
defensively). 6 unit tests covering anchor-mode splice,
append-mode-no-anchors, excluded-section drop, placeholder
resolution, lang column pick, order_index ASC.
- internal/services/submission_section_service.go: SectionPatch +
Update method. Six optional fields (content_md_de/en, included,
label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
RLS-filtered miss.
- internal/handlers/submission_sections.go (NEW, ~150 LoC):
PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
cross-check. 404 on both missing-draft and section-belongs-elsewhere
paths.
- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
→ existing firmSkeletonSubmissionSlug, neutral → existing
skeletonSubmissionSlug.
- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
branches on draft.BaseID. When set AND base + bytes + sections all
resolve → Composer pipeline. Else v1 fallback render path stays.
Audit metadata jsonb gains "composer": true + "base_id" flag when
composer was used.
Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
inside section content).
Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
contentEditable per included section with a per-section B/I
toolbar. Per-section autosave debounced 500ms; mousedown handlers on
toolbar buttons preserve editor focus mid-command. domToMarkdown
walks the contentEditable's DOM tree back to Markdown source-of-
truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
→ newline). Updated state.view.sections in-place on PATCH success
without re-painting (avoids focus-stealing on every keystroke);
re-paints only on structural changes (included toggle, label edits,
order changes).
- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
section via PATCH. flushSectionAutosave on blur force-flushes
pending edits so leaving an editor doesn't strand unsynced changes.
- styles/global.css: editor surface (contentEditable area with focus
ring + placeholder), toolbar buttons (B/I 1.8rem squares),
per-section "Hide"/"Include" toggle in the head row.
- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
500ms. Letztes Layout in Word."
Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
sectPr → firm header/footer rIds): blob SHA
f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
(default true) so future regens emit composer-ready bodies. The
_firm-skeleton.docx regen was done via a one-off /tmp helper since
the gen-hl-skeleton-template script requires the proprietary .dotm
source which lives in HL/mWorkRepo; extending that script to accept
an existing .docx as input is a follow-up cleanup.
Build hygiene: go build/vet/test -short ./internal/... ./cmd/... all
clean; bun run build clean (2900 i18n keys, data-i18n scan clean).
NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.
Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).
NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
this slice per head's brief msg #2393).
t-paliad-313 Slice B
149 lines
4.4 KiB
Go
149 lines
4.4 KiB
Go
package handlers
|
|
|
|
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
|
|
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
|
|
// where the lawyer types prose into each section.
|
|
//
|
|
// Endpoint:
|
|
//
|
|
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
|
|
//
|
|
// Body shape (all fields optional — absent = no change):
|
|
//
|
|
// {
|
|
// "content_md_de": "...",
|
|
// "content_md_en": "...",
|
|
// "included": true|false,
|
|
// "label_de": "...",
|
|
// "label_en": "...",
|
|
// "order_index": 3
|
|
// }
|
|
//
|
|
// Visibility: ownership of the draft is checked via
|
|
// SubmissionDraftService.Get (404 on no-access), then the section is
|
|
// fetched + verified to belong to that draft. The DB-side RLS policy
|
|
// (mig 148) enforces the same gate independently.
|
|
//
|
|
// Returns 200 + the refreshed section row on success.
|
|
//
|
|
// This is global-scoped (no /projects/{id}/ prefix) because the
|
|
// section's owning draft already carries the project_id; routing on
|
|
// section_id alone keeps the URL shape stable across project-scoped
|
|
// and project-less drafts.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
|
|
type submissionSectionPatchInput struct {
|
|
ContentMDDE *string `json:"content_md_de,omitempty"`
|
|
ContentMDEN *string `json:"content_md_en,omitempty"`
|
|
Included *bool `json:"included,omitempty"`
|
|
LabelDE *string `json:"label_de,omitempty"`
|
|
LabelEN *string `json:"label_en,omitempty"`
|
|
OrderIndex *int `json:"order_index,omitempty"`
|
|
}
|
|
|
|
// submissionSectionPatchTimeout caps the round-trip.
|
|
const submissionSectionPatchTimeout = 10 * time.Second
|
|
|
|
func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
|
defer cancel()
|
|
|
|
// Owner-scope on the draft (RLS mirror; this gives us the typed
|
|
// 404 + the path for the "section belongs to a different draft"
|
|
// case below).
|
|
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
existing, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if existing.DraftID != draft.ID {
|
|
// Section exists but doesn't belong to this draft — surface as
|
|
// 404 to keep the "no fishing for foreign drafts" property.
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
|
return
|
|
}
|
|
|
|
var input submissionSectionPatchInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
|
|
patch := services.SectionPatch{
|
|
ContentMDDE: input.ContentMDDE,
|
|
ContentMDEN: input.ContentMDEN,
|
|
Included: input.Included,
|
|
LabelDE: input.LabelDE,
|
|
LabelEN: input.LabelEN,
|
|
OrderIndex: input.OrderIndex,
|
|
}
|
|
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
|
}
|
|
|
|
// sectionJSONFromService projects a services.SubmissionSection into the
|
|
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
|
// emits under .sections[].
|
|
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
|
|
return submissionSectionJSON{
|
|
ID: sec.ID,
|
|
SectionKey: sec.SectionKey,
|
|
OrderIndex: sec.OrderIndex,
|
|
Kind: sec.Kind,
|
|
LabelDE: sec.LabelDE,
|
|
LabelEN: sec.LabelEN,
|
|
Included: sec.Included,
|
|
ContentMDDE: sec.ContentMDDE,
|
|
ContentMDEN: sec.ContentMDEN,
|
|
}
|
|
}
|