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
1464 lines
52 KiB
Go
1464 lines
52 KiB
Go
package handlers
|
|
|
|
// Submission draft handlers — backing API + page route for the
|
|
// dedicated Submissions/Schriftsätze editor at
|
|
// /projects/{id}/submissions/{code}/draft (t-paliad-238 Slice A,
|
|
// design doc docs/design-submission-page-2026-05-22.md §6.3).
|
|
//
|
|
// Endpoints:
|
|
//
|
|
// GET /projects/{id}/submissions/{code}/draft (page)
|
|
// GET /projects/{id}/submissions/{code}/draft/{draft_id} (page)
|
|
//
|
|
// GET /api/projects/{id}/submissions/{code}/drafts
|
|
// POST /api/projects/{id}/submissions/{code}/drafts
|
|
// GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}
|
|
// PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}
|
|
// DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}
|
|
// GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview
|
|
// POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export
|
|
//
|
|
// Visibility: every endpoint funnels through SubmissionDraftService,
|
|
// whose Get/List/Create gate on ProjectService.GetByID
|
|
// (paliad.can_see_project) and additionally restrict to owner-only
|
|
// reads for individual drafts. RLS in the DB enforces the same
|
|
// constraint independently.
|
|
//
|
|
// Slice A template source: the universal HL Patents Style .dotm
|
|
// (same path the t-paliad-230 format-only /generate uses). Per-
|
|
// submission_code templates with the fallback chain land in Slice B.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// submissionDraftPreviewTimeout caps a single preview round-trip.
|
|
const submissionDraftPreviewTimeout = 10 * time.Second
|
|
|
|
// submissionDraftExportTimeout caps a single export.
|
|
const submissionDraftExportTimeout = 30 * time.Second
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Request / response shapes
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// submissionDraftView is the JSON shape returned to the editor — the
|
|
// raw row plus the resolved bag and the rule metadata the sidebar uses
|
|
// to label each variable group.
|
|
type submissionDraftView struct {
|
|
Draft submissionDraftJSON `json:"draft"`
|
|
Rule *submissionRuleSummary `json:"rule,omitempty"`
|
|
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
|
|
MergedBag services.PlaceholderMap `json:"merged_bag"`
|
|
PreviewHTML string `json:"preview_html"`
|
|
Lang string `json:"lang"`
|
|
HasTemplate bool `json:"has_template"`
|
|
TemplateMissing bool `json:"template_missing,omitempty"`
|
|
// TemplateTier identifies which tier of resolveSubmissionTemplate
|
|
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
|
|
// skeleton, letterhead. Lets the editor distinguish a perfect
|
|
// per-firm match from a skeleton fallback. t-paliad-276.
|
|
TemplateTier string `json:"template_tier,omitempty"`
|
|
// LanguageFallback is true when the requested draft.language has no
|
|
// per-firm per-code template (e.g. EN draft falls back to the DE
|
|
// per-code template, or to the universal skeleton). UI surfaces a
|
|
// notice so the lawyer knows the rendered body lacks language-
|
|
// matched code-specific prose. t-paliad-276.
|
|
LanguageFallback bool `json:"language_fallback,omitempty"`
|
|
// AvailableParties is the project's full party roster (t-paliad-277)
|
|
// so the frontend can render the multi-select picker in one round-
|
|
// trip. Empty when the draft has no project attached.
|
|
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
|
|
// Sections is the per-draft section stack (t-paliad-313 Slice A).
|
|
// Slice A renders these read-only; the lawyer sees what the
|
|
// Composer seeded but can't yet edit prose. nil for pre-Composer
|
|
// drafts (base_id NULL, no submission_sections rows).
|
|
Sections []submissionSectionJSON `json:"sections"`
|
|
}
|
|
|
|
// submissionDraftPartyJSON is the minimal party row the editor sidebar
|
|
// needs to render a checkbox + role chip per party.
|
|
type submissionDraftPartyJSON struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role,omitempty"`
|
|
Representative string `json:"representative,omitempty"`
|
|
}
|
|
|
|
type submissionDraftJSON struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ProjectID *uuid.UUID `json:"project_id"`
|
|
SubmissionCode string `json:"submission_code"`
|
|
UserID uuid.UUID `json:"user_id"`
|
|
Name string `json:"name"`
|
|
Language string `json:"language"`
|
|
Variables services.PlaceholderMap `json:"variables"`
|
|
SelectedParties []uuid.UUID `json:"selected_parties"`
|
|
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
|
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
|
|
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
|
|
// BaseID — Composer base reference (t-paliad-313). NULL on
|
|
// pre-Composer drafts; the editor sidebar surfaces this in the
|
|
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
|
// {"base_id": null} to set or clear.
|
|
BaseID *uuid.UUID `json:"base_id"`
|
|
ComposerMeta map[string]any `json:"composer_meta"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// submissionSectionJSON is the on-the-wire row for each per-draft
|
|
// section. Slice A renders these read-only — the lawyer sees the
|
|
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
|
// editable + adds the PATCH endpoint.
|
|
type submissionSectionJSON struct {
|
|
ID uuid.UUID `json:"id"`
|
|
SectionKey string `json:"section_key"`
|
|
OrderIndex int `json:"order_index"`
|
|
Kind string `json:"kind"`
|
|
LabelDE string `json:"label_de"`
|
|
LabelEN string `json:"label_en"`
|
|
Included bool `json:"included"`
|
|
ContentMDDE string `json:"content_md_de"`
|
|
ContentMDEN string `json:"content_md_en"`
|
|
}
|
|
|
|
type submissionRuleSummary struct {
|
|
Name string `json:"name"`
|
|
NameEN string `json:"name_en"`
|
|
SubmissionCode string `json:"submission_code"`
|
|
PrimaryParty string `json:"primary_party,omitempty"`
|
|
EventType string `json:"event_type,omitempty"`
|
|
LegalSource string `json:"legal_source,omitempty"`
|
|
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
|
|
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
|
|
}
|
|
|
|
type submissionDraftListResponse struct {
|
|
ProjectID uuid.UUID `json:"project_id"`
|
|
SubmissionCode string `json:"submission_code"`
|
|
Drafts []submissionDraftJSON `json:"drafts"`
|
|
}
|
|
|
|
type submissionDraftPatchInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
|
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
|
Language *string `json:"language,omitempty"`
|
|
// BaseID accepts three states per the JSON contract:
|
|
// field absent → no change (json:"-")
|
|
// {"base_id": "<uuid>"} → set to picked base
|
|
// {"base_id": null} → clear (return to v1 fallback)
|
|
// We model this with a **uuid.UUID inside a custom UnmarshalJSON
|
|
// in case extends; for now the simpler `*uuid.UUID` + presence
|
|
// flag covers Slice A's set-base flow. Clearing is exposed but
|
|
// rarely used (the editor always picks a base; clearing is for
|
|
// admin-recovery flows).
|
|
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
|
BaseIDSet bool `json:"-"`
|
|
}
|
|
|
|
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
|
// the "base_id" key appears in the payload (regardless of whether
|
|
// the value is null or a uuid string). Lets the handler distinguish
|
|
// "field absent" (no change) from "field set to null" (clear).
|
|
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
|
// Phase 1: decode into a raw map to detect key presence.
|
|
raw := map[string]json.RawMessage{}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
// Phase 2: decode the typed fields. Use an alias to skip this
|
|
// custom UnmarshalJSON during the re-parse.
|
|
type alias submissionDraftPatchInput
|
|
var a alias
|
|
if err := json.Unmarshal(data, &a); err != nil {
|
|
return err
|
|
}
|
|
*p = submissionDraftPatchInput(a)
|
|
if _, ok := raw["base_id"]; ok {
|
|
p.BaseIDSet = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Handlers
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// userSubmissionDraftRow is the on-the-wire shape for the global
|
|
// /submissions index — each draft enriched with the project's title +
|
|
// reference for the row.
|
|
//
|
|
// ProjectID / ProjectTitle / ProjectReference are nullable since
|
|
// t-paliad-243 — a global Schriftsatz draft started from
|
|
// /submissions/new without binding a project surfaces here with all
|
|
// three set to null, and the frontend renders a dedicated
|
|
// "Ohne Projekt" label for the project column.
|
|
type userSubmissionDraftRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ProjectID *uuid.UUID `json:"project_id"`
|
|
ProjectTitle *string `json:"project_title"`
|
|
ProjectReference *string `json:"project_reference,omitempty"`
|
|
SubmissionCode string `json:"submission_code"`
|
|
Name string `json:"name"`
|
|
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// handleListUserSubmissionDrafts returns every draft the caller owns
|
|
// across every visible project, ordered by updated_at DESC. Backs the
|
|
// global /submissions index page (t-paliad-240).
|
|
func handleListUserSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "submission drafts not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
rows, err := dbSvc.submissionDraft.ListAllForUser(r.Context(), uid)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
out := make([]userSubmissionDraftRow, 0, len(rows))
|
|
for i := range rows {
|
|
d := &rows[i]
|
|
out = append(out, userSubmissionDraftRow{
|
|
ID: d.ID,
|
|
ProjectID: d.ProjectID,
|
|
ProjectTitle: d.ProjectTitle,
|
|
ProjectReference: d.ProjectReference,
|
|
SubmissionCode: d.SubmissionCode,
|
|
Name: d.Name,
|
|
LastExportedAt: d.LastExportedAt,
|
|
UpdatedAt: d.UpdatedAt,
|
|
CreatedAt: d.CreatedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"drafts": out})
|
|
}
|
|
|
|
// handleSubmissionsIndexPage serves dist/submissions-index.html for the
|
|
// global /submissions index — lists every draft the caller owns across
|
|
// visible projects. Sits at top level alongside /checklists, /courts etc.
|
|
func handleSubmissionsIndexPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/submissions-index.html")
|
|
}
|
|
|
|
// handleListSubmissionDrafts returns every draft the caller owns for
|
|
// the given (project, submission_code).
|
|
func handleListSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseUUIDPath(w, r, "id", "project id")
|
|
if !ok {
|
|
return
|
|
}
|
|
code, ok := parseSubmissionCodePath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "submission drafts not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
rows, err := dbSvc.submissionDraft.List(r.Context(), uid, projectID, code)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
resp := submissionDraftListResponse{
|
|
ProjectID: projectID,
|
|
SubmissionCode: code,
|
|
Drafts: make([]submissionDraftJSON, 0, len(rows)),
|
|
}
|
|
for i := range rows {
|
|
resp.Drafts = append(resp.Drafts, draftToJSON(&rows[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleCreateSubmissionDraft creates a fresh draft and returns the
|
|
// view payload (so the client can navigate to /draft/{id} immediately).
|
|
func handleCreateSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseUUIDPath(w, r, "id", "project id")
|
|
if !ok {
|
|
return
|
|
}
|
|
code, ok := parseSubmissionCodePath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "submission drafts not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
|
|
d, err := dbSvc.submissionDraft.Create(r.Context(), uid, &projectID, code, lang)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view after create (project=%s code=%s): %v", projectID, code, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, view)
|
|
}
|
|
|
|
// handleGetSubmissionDraft returns the full editor payload — draft row
|
|
// + resolved bag + merged bag (with overrides applied) + HTML preview.
|
|
func handleGetSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
|
|
return
|
|
}
|
|
if _, ok := parseSubmissionCodePath(w, r); !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
d, err := dbSvc.submissionDraft.Get(r.Context(), uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, view)
|
|
}
|
|
|
|
// handlePatchSubmissionDraft updates name and/or variables and returns
|
|
// the refreshed view payload (so the preview pane updates in lockstep
|
|
// with the save).
|
|
func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
|
|
return
|
|
}
|
|
if _, ok := parseSubmissionCodePath(w, r); !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
var input submissionDraftPatchInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
|
|
patch := services.DraftPatch{
|
|
Name: input.Name,
|
|
Variables: input.Variables,
|
|
SelectedParties: input.SelectedParties,
|
|
Language: input.Language,
|
|
}
|
|
if input.BaseIDSet {
|
|
patch.BaseID = &input.BaseID
|
|
}
|
|
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view after patch (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, view)
|
|
}
|
|
|
|
// handleDeleteSubmissionDraft removes a draft.
|
|
func handleDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
|
|
return
|
|
}
|
|
if _, ok := parseSubmissionCodePath(w, r); !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
if err := dbSvc.submissionDraft.Delete(r.Context(), uid, draftID); err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handlePreviewSubmissionDraft re-renders the HTML preview with the
|
|
// current draft state. Cheaper alternative to the full GET when the
|
|
// frontend just wants the preview block refreshed.
|
|
func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
|
|
return
|
|
}
|
|
if _, ok := parseSubmissionCodePath(w, r); !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftPreviewTimeout)
|
|
defer cancel()
|
|
|
|
d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
|
return
|
|
}
|
|
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: render preview (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
|
|
}
|
|
|
|
// handleExportSubmissionDraft merges the draft into the .docx template
|
|
// and streams the result. Writes one system_audit_log row and one
|
|
// project_events row per successful export.
|
|
func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
|
|
return
|
|
}
|
|
if _, ok := parseSubmissionCodePath(w, r); !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftExportTimeout)
|
|
defer cancel()
|
|
|
|
d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
|
writeSubmissionExportError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
|
|
|
// Audit + provenance updates are best-effort on a background
|
|
// context so the download still succeeds if the DB races.
|
|
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancelBG()
|
|
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
|
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
|
}
|
|
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
|
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
|
}
|
|
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
|
log.Printf("submission_drafts: project event insert failed (draft=%s): %v", draftID, err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", docxMime)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
|
|
if _, err := w.Write(docx); err != nil {
|
|
log.Printf("submission_drafts: response write failed (draft=%s): %v", draftID, err)
|
|
}
|
|
}
|
|
|
|
// exportSubmissionDraft is the shared render entry point used by both
|
|
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
|
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
|
// Composer pipeline assembles the document; otherwise the v1
|
|
// template-only path stays the fallback. composerUsed = true means the
|
|
// metadata jsonb on the audit row carries "composer": true so admins
|
|
// can tell the two paths apart in the feed.
|
|
//
|
|
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
|
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
|
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
|
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
|
switch {
|
|
case err == nil:
|
|
baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
|
|
if err == nil {
|
|
sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
|
if err != nil {
|
|
return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
|
|
}
|
|
bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
|
if err != nil {
|
|
return nil, nil, "", false, err
|
|
}
|
|
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
|
|
Sections: sections,
|
|
Base: base,
|
|
BaseBytes: baseBytes,
|
|
Lang: resolved.Lang,
|
|
Vars: bag,
|
|
Missing: services.DefaultMissingMarker(resolved.Lang),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
|
|
}
|
|
return docx, resolved, baseSHA, true, nil
|
|
}
|
|
log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
|
|
case errors.Is(err, services.ErrBaseNotFound):
|
|
log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
|
|
default:
|
|
return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
|
|
}
|
|
}
|
|
|
|
// v1 fallback: template-only render via resolveSubmissionTemplate +
|
|
// SubmissionDraftService.Export. Unchanged behaviour for
|
|
// pre-Composer drafts.
|
|
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
|
if err != nil {
|
|
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
|
|
}
|
|
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
|
if err != nil {
|
|
return nil, nil, "", false, fmt.Errorf("render: %w", err)
|
|
}
|
|
return docx, resolved, tplSHA, false, nil
|
|
}
|
|
|
|
// writeSubmissionExportError maps a render-time error to an HTTP
|
|
// response. The shape mirrors what the handlers used to inline.
|
|
func writeSubmissionExportError(w http.ResponseWriter, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
msg := err.Error()
|
|
switch {
|
|
case strings.Contains(msg, "template upstream"):
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
|
case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
|
}
|
|
}
|
|
|
|
// handleSubmissionDraftPage serves dist/submission-draft.html for the
|
|
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
|
|
// (and …/draft/{draft_id}). Project visibility is enforced server-side
|
|
// before serving so an inaccessible id returns 404 + notfound chrome
|
|
// rather than leaking a 200 to a guesser. Submission rule lookup +
|
|
// draft id existence checks happen client-side via the API endpoints.
|
|
func handleSubmissionDraftPage(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
serveSubmissionDraftNotFound(w)
|
|
return
|
|
}
|
|
if _, err := dbSvc.projects.GetByID(r.Context(), uid, id); err != nil {
|
|
if errors.Is(err, services.ErrNotVisible) {
|
|
serveSubmissionDraftNotFound(w)
|
|
return
|
|
}
|
|
writeServiceError(httpDevNullJSON{}, err)
|
|
serveSubmissionDraftNotFound(w)
|
|
return
|
|
}
|
|
http.ServeFile(w, r, "dist/submission-draft.html")
|
|
}
|
|
|
|
// handleSubmissionDraftGlobalPage serves dist/submission-draft.html for
|
|
// project-less drafts at /submissions/draft/{draft_id} (t-paliad-243).
|
|
// The page shell is identical to the project-scoped one; the client
|
|
// bundle parses the URL and switches to no-project mode when no
|
|
// /projects/{id}/ prefix is present.
|
|
//
|
|
// Owner check happens at the API layer when the client fetches the
|
|
// draft view; this handler only guards the page chrome and leaves the
|
|
// 404-on-not-found semantics to the API.
|
|
func handleSubmissionDraftGlobalPage(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
if _, err := uuid.Parse(r.PathValue("draft_id")); err != nil {
|
|
serveSubmissionDraftNotFound(w)
|
|
return
|
|
}
|
|
http.ServeFile(w, r, "dist/submission-draft.html")
|
|
}
|
|
|
|
// handleSubmissionsNewPage serves dist/submissions-new.html — the
|
|
// global "Neuer Entwurf" picker (t-paliad-243). The page lists the
|
|
// full submission catalog grouped by proceeding and lets the lawyer
|
|
// pick a template with or without binding it to a project.
|
|
func handleSubmissionsNewPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/submissions-new.html")
|
|
}
|
|
|
|
// globalCreateDraftInput is the body shape for POST /api/submission-drafts.
|
|
// project_id is optional; when omitted or null the draft is created
|
|
// without a project binding (t-paliad-243).
|
|
type globalCreateDraftInput struct {
|
|
SubmissionCode string `json:"submission_code"`
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
}
|
|
|
|
// handleCreateGlobalSubmissionDraft creates a draft from the global
|
|
// /submissions/new picker. Compared to the project-scoped sibling, the
|
|
// project_id comes from the JSON body (optional) instead of the URL
|
|
// path. Used by the picker to spawn a draft and redirect to the editor.
|
|
func handleCreateGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "submission drafts not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
var in globalCreateDraftInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
code := strings.TrimSpace(in.SubmissionCode)
|
|
if code == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission_code required"})
|
|
return
|
|
}
|
|
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
|
|
d, err := dbSvc.submissionDraft.Create(r.Context(), uid, in.ProjectID, code, lang)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view after global create (code=%s): %v", code, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, view)
|
|
}
|
|
|
|
// handleGetGlobalSubmissionDraft returns the editor payload by draft
|
|
// id alone (t-paliad-243). The project-less editor at
|
|
// /submissions/draft/{draft_id} doesn't have a project segment to
|
|
// route through the existing project-scoped endpoint; this is the
|
|
// global counterpart. Works for both project-less AND project-scoped
|
|
// drafts since the draft row carries its own project_id.
|
|
func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
d, err := dbSvc.submissionDraft.Get(r.Context(), uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, view)
|
|
}
|
|
|
|
// globalDraftPatchInput is PATCH input for /api/submission-drafts/{draft_id}.
|
|
// Same Name + Variables semantics as the project-scoped patch, plus
|
|
// ProjectID for the "Projekt zuweisen" affordance — the lawyer can
|
|
// attach (assign a UUID) or detach (set null) at any time. A missing
|
|
// `project_id` key is treated as "no change"; a present-but-null value
|
|
// detaches.
|
|
type globalDraftPatchInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
|
Language *string `json:"language,omitempty"`
|
|
// projectIDProvided is true when the JSON included the "project_id"
|
|
// key (regardless of value); needed to distinguish "no change" from
|
|
// "set to null". Set by the custom UnmarshalJSON below.
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
projectIDProvided bool
|
|
// SelectedParties: present-but-empty array resets to "all parties",
|
|
// present non-empty array restricts to subset, absent = no change.
|
|
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
|
// BaseID + baseIDProvided mirror the ProjectID pattern — present
|
|
// (regardless of value) means "set"; absent means "no change". Set
|
|
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
|
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
|
baseIDProvided bool
|
|
}
|
|
|
|
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
|
type alias struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
|
Language *string `json:"language,omitempty"`
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
|
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
|
}
|
|
var a alias
|
|
if err := json.Unmarshal(data, &a); err != nil {
|
|
return err
|
|
}
|
|
g.Name = a.Name
|
|
g.Variables = a.Variables
|
|
g.Language = a.Language
|
|
g.ProjectID = a.ProjectID
|
|
g.SelectedParties = a.SelectedParties
|
|
g.BaseID = a.BaseID
|
|
// Detect whether "project_id" / "base_id" were present in the JSON
|
|
// object.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
_, g.projectIDProvided = raw["project_id"]
|
|
_, g.baseIDProvided = raw["base_id"]
|
|
return nil
|
|
}
|
|
|
|
// handleGlobalPatchSubmissionDraft updates a draft by id (t-paliad-243).
|
|
// Supports the project_id mutation in addition to name + variables so
|
|
// the project-less editor can offer "Projekt zuweisen" and persist the
|
|
// chosen project on the same row.
|
|
func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
var in globalDraftPatchInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
|
|
patch := services.DraftPatch{
|
|
Name: in.Name,
|
|
Variables: in.Variables,
|
|
SelectedParties: in.SelectedParties,
|
|
Language: in.Language,
|
|
}
|
|
if in.projectIDProvided {
|
|
pid := in.ProjectID // may be nil → detach
|
|
patch.ProjectID = &pid
|
|
}
|
|
if in.baseIDProvided {
|
|
bid := in.BaseID // may be nil → clear
|
|
patch.BaseID = &bid
|
|
}
|
|
|
|
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view after global patch (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, view)
|
|
}
|
|
|
|
// handleImportFromProject re-pulls every project-derived variable on
|
|
// the draft and bumps last_imported_at (t-paliad-277). The service-
|
|
// layer call strips overrides for project.* / parties.* / deadline.* /
|
|
// procedural_event.* / rule.* prefixes; firm.* / today.* / user.*
|
|
// overrides survive because those values aren't sourced from the
|
|
// project record.
|
|
//
|
|
// Idempotent on repeat clicks. Returns the full editor view so the
|
|
// frontend can refresh in one round-trip.
|
|
func handleImportFromProject(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
d, err := dbSvc.submissionDraft.ImportFromProject(r.Context(), uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
user, _ := dbSvc.users.GetByID(r.Context(), uid)
|
|
lang := userLang(user)
|
|
view, err := buildSubmissionDraftView(r.Context(), d, lang)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: build view after import (draft=%s): %v", draftID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, view)
|
|
}
|
|
|
|
// handleGlobalDeleteSubmissionDraft removes a draft by id.
|
|
func handleGlobalDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
if err := dbSvc.submissionDraft.Delete(r.Context(), uid, draftID); err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleGlobalExportSubmissionDraft renders and streams the .docx for
|
|
// the draft. Shares writeSubmissionDraftAuditRow + writeSubmissionDraftProjectEvent
|
|
// with the project-scoped sibling — both handle a nil ProjectID
|
|
// correctly (audit scope flips to 'user', project-event is skipped).
|
|
func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftExportTimeout)
|
|
defer cancel()
|
|
|
|
d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
|
if err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
|
writeSubmissionExportError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
|
|
|
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancelBG()
|
|
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
|
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
|
}
|
|
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
|
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
|
}
|
|
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
|
log.Printf("submission_drafts: project event insert failed (draft=%s): %v", draftID, err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", docxMime)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
|
|
if _, err := w.Write(docx); err != nil {
|
|
log.Printf("submission_drafts: response write failed (draft=%s): %v", draftID, err)
|
|
}
|
|
}
|
|
|
|
// serveSubmissionDraftNotFound writes the standard notfound chrome
|
|
// with a 404 status — same shape the chart page uses.
|
|
func serveSubmissionDraftNotFound(w http.ResponseWriter) {
|
|
body, err := os.ReadFile("dist/notfound.html")
|
|
if err != nil {
|
|
http.Error(w, "404 page not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// buildSubmissionDraftView composes the full editor payload: the draft
|
|
// row, the resolved bag from project state, the merged bag (overrides
|
|
// layered on top), the HTML preview, and metadata for the sidebar's
|
|
// per-rule heading.
|
|
func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, lang string) (*submissionDraftView, error) {
|
|
view := &submissionDraftView{
|
|
Draft: draftToJSON(d),
|
|
Lang: lang,
|
|
HasTemplate: true,
|
|
AvailableParties: []submissionDraftPartyJSON{},
|
|
Sections: []submissionSectionJSON{},
|
|
}
|
|
|
|
// Composer Slice A — surface seeded sections (read-only). Empty
|
|
// when the draft has no base + no section rows (pre-Composer
|
|
// drafts that haven't been auto-upgraded — that's Slice C).
|
|
if dbSvc.submissionSection != nil {
|
|
secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, sec := range secs {
|
|
view.Sections = append(view.Sections, 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view.ResolvedBag = resolved.Placeholders
|
|
view.MergedBag = merged
|
|
if resolved.Lang != "" {
|
|
view.Lang = resolved.Lang
|
|
}
|
|
if len(resolved.Parties) > 0 {
|
|
view.AvailableParties = make([]submissionDraftPartyJSON, 0, len(resolved.Parties))
|
|
for _, p := range resolved.Parties {
|
|
row := submissionDraftPartyJSON{ID: p.ID, Name: p.Name}
|
|
if p.Role != nil {
|
|
row.Role = *p.Role
|
|
}
|
|
if p.Representative != nil {
|
|
row.Representative = *p.Representative
|
|
}
|
|
view.AvailableParties = append(view.AvailableParties, row)
|
|
}
|
|
}
|
|
if resolved.Rule != nil {
|
|
view.Rule = &submissionRuleSummary{
|
|
Name: derefStringHandler(resolved.Rule.SubmissionCode),
|
|
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
|
|
NameEN: resolved.Rule.NameEN,
|
|
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
|
|
EventType: derefStringHandler(resolved.Rule.EventType),
|
|
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
|
|
}
|
|
view.Rule.Name = resolved.Rule.Name
|
|
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
|
|
}
|
|
|
|
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
|
if err != nil {
|
|
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
|
|
view.TemplateMissing = true
|
|
view.HasTemplate = false
|
|
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
|
|
return view, nil
|
|
}
|
|
view.TemplateTier = string(tier)
|
|
// LanguageFallback signals "no per-firm template in the requested
|
|
// language" — the editor surfaces a notice so the lawyer knows the
|
|
// rendered body lacks code-specific prose. The per-code DE template
|
|
// counts as a fallback when the requested language is EN.
|
|
view.LanguageFallback = languageFallback(d.Language, tier)
|
|
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view.PreviewHTML = html
|
|
return view, nil
|
|
}
|
|
|
|
// submissionTemplateTier enumerates which tier of the template
|
|
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
|
|
// Used by the editor to surface "Fallback: universelles Skelett" when
|
|
// the requested (code, lang) didn't have a dedicated template.
|
|
type submissionTemplateTier string
|
|
|
|
const (
|
|
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
|
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
|
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
|
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
|
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
|
)
|
|
|
|
// resolveSubmissionTemplate returns the .docx bytes for the given
|
|
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
|
|
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
|
|
//
|
|
// 1. per-firm per-(code, lang) template — most specific. e.g.
|
|
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
|
|
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
|
|
// legacy registry shape from before the language selector landed.
|
|
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
|
|
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
|
|
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
|
|
// HL paragraph + character styles + letterhead, full placeholder
|
|
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
|
|
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
|
|
// Backstop when the firm skeleton is unreachable.
|
|
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
|
|
// placeholders. Last-ditch when every skeleton tier is unreachable.
|
|
//
|
|
// The returned SHA pins the audit row's template provenance. The tier
|
|
// tells the editor whether the result language-matches the request so
|
|
// it can surface a "Fallback: universelles Skelett" notice.
|
|
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
|
|
if lang != "de" && lang != "en" {
|
|
lang = "de"
|
|
}
|
|
// 1. per-(code, lang)
|
|
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
|
|
return nil, "", "", err
|
|
} else if found {
|
|
return data, sha, tplTierPerCodeLang, nil
|
|
}
|
|
// 2. per-code (unsuffixed)
|
|
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
|
|
return nil, "", "", err
|
|
} else if found {
|
|
return data, sha, tplTierPerCode, nil
|
|
}
|
|
// 3. language-matched skeleton — only meaningful for EN drafts; DE
|
|
// drafts fall through to the firm/universal DE skeletons below.
|
|
if lang == "en" {
|
|
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
|
|
return data, sha, tplTierSkeletonLang, nil
|
|
}
|
|
}
|
|
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
|
|
// this is a first-class match; for EN drafts it counts as a
|
|
// language fallback (handled by languageFallback()).
|
|
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
|
|
return data, sha, tplTierSkeleton, nil
|
|
} else {
|
|
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
|
|
}
|
|
// 5. universal plain DE skeleton.
|
|
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
|
return data, sha, tplTierSkeleton, nil
|
|
} else {
|
|
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
|
|
}
|
|
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
|
|
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
sha := hlPatentsStyleSHA()
|
|
return bytes, sha, tplTierLetterhead, nil
|
|
}
|
|
|
|
// languageFallback reports whether the resolved template tier failed
|
|
// to match the requested draft language. For an EN draft, anything
|
|
// other than per_code_lang or skeleton_lang is a fallback (per_code is
|
|
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
|
|
// draft, only `letterhead` counts as a fallback — the DE skeleton and
|
|
// per-code template are both first-class DE outputs. t-paliad-276.
|
|
func languageFallback(lang string, tier submissionTemplateTier) bool {
|
|
if tier == tplTierLetterhead {
|
|
return true
|
|
}
|
|
if strings.EqualFold(lang, "en") {
|
|
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hlPatentsStyleSHA reads the current cache SHA for the universal
|
|
// template if one is loaded. Returns "" when the cache hasn't been
|
|
// populated yet — the export still proceeds, the audit row just
|
|
// records an unpinned provenance.
|
|
func hlPatentsStyleSHA() string {
|
|
ce := getCacheEntry(hlPatentsStyleSlug)
|
|
ce.mu.RLock()
|
|
defer ce.mu.RUnlock()
|
|
return ce.sha
|
|
}
|
|
|
|
// draftToJSON projects a services.SubmissionDraft into the on-the-wire
|
|
// shape — strips the raw jsonb bytes and exposes the decoded
|
|
// PlaceholderMap directly.
|
|
func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
|
vars := d.Variables
|
|
if vars == nil {
|
|
vars = services.PlaceholderMap{}
|
|
}
|
|
selected := d.SelectedParties
|
|
if selected == nil {
|
|
selected = []uuid.UUID{}
|
|
}
|
|
lang := d.Language
|
|
if lang == "" {
|
|
lang = "de"
|
|
}
|
|
meta := d.ComposerMeta
|
|
if meta == nil {
|
|
meta = map[string]any{}
|
|
}
|
|
return submissionDraftJSON{
|
|
ID: d.ID,
|
|
ProjectID: d.ProjectID,
|
|
SubmissionCode: d.SubmissionCode,
|
|
UserID: d.UserID,
|
|
Name: d.Name,
|
|
Language: lang,
|
|
Variables: vars,
|
|
SelectedParties: selected,
|
|
LastExportedAt: d.LastExportedAt,
|
|
LastExportedSHA: d.LastExportedSHA,
|
|
LastImportedAt: d.LastImportedAt,
|
|
BaseID: d.BaseID,
|
|
ComposerMeta: meta,
|
|
CreatedAt: d.CreatedAt,
|
|
UpdatedAt: d.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// writeSubmissionDraftAuditRow logs one row in paliad.system_audit_log
|
|
// per export. Distinct event_type from the format-only 'submission.generated'
|
|
// so admins can tell the two paths apart in the feed.
|
|
//
|
|
// For a project-less draft (t-paliad-243) the scope is widened to
|
|
// 'user' with scope_root = draft.user_id; the audit feed therefore
|
|
// surfaces these exports on the user's row rather than against a
|
|
// (non-existent) project.
|
|
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string, composerUsed bool) error {
|
|
meta := map[string]any{
|
|
"submission_code": d.SubmissionCode,
|
|
"draft_id": d.ID.String(),
|
|
"draft_name": d.Name,
|
|
"filename": filename,
|
|
"template_sha": templateSHA,
|
|
}
|
|
// t-paliad-313 Slice B — composer flag in metadata so admins can
|
|
// tell the two render paths apart in the audit feed without
|
|
// adding a new event_type.
|
|
if composerUsed {
|
|
meta["composer"] = true
|
|
if d.BaseID != nil {
|
|
meta["base_id"] = d.BaseID.String()
|
|
}
|
|
}
|
|
body, _ := json.Marshal(meta)
|
|
var (
|
|
actorID any
|
|
actorEmail string
|
|
)
|
|
if user != nil {
|
|
actorID = user.ID
|
|
actorEmail = user.Email
|
|
}
|
|
scope := "project"
|
|
scopeRoot := ""
|
|
if d.ProjectID != nil {
|
|
scopeRoot = d.ProjectID.String()
|
|
} else {
|
|
scope = "user"
|
|
scopeRoot = d.UserID.String()
|
|
}
|
|
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
|
`INSERT INTO paliad.system_audit_log
|
|
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
|
VALUES ('submission.exported', $1, $2, $3, $4, $5::jsonb)`,
|
|
actorID, actorEmail, scope, scopeRoot, string(body),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// writeSubmissionDraftProjectEvent records a project-level Verlauf
|
|
// entry so the export surfaces on the project's timeline. Uses the
|
|
// same event_type convention as other custom milestones. A project-less
|
|
// draft (t-paliad-243) has no timeline to write to — the caller skips
|
|
// this row entirely; we no-op defensively here for safety.
|
|
func writeSubmissionDraftProjectEvent(ctx context.Context, d *services.SubmissionDraft, resolved *services.SubmissionVarsResult, filename string) error {
|
|
if d.ProjectID == nil {
|
|
return nil
|
|
}
|
|
ruleName := ""
|
|
if resolved.Rule != nil {
|
|
ruleName = resolved.Rule.Name
|
|
}
|
|
meta := map[string]any{
|
|
"submission_code": d.SubmissionCode,
|
|
"draft_id": d.ID.String(),
|
|
"draft_name": d.Name,
|
|
"rule_name": ruleName,
|
|
"filename": filename,
|
|
}
|
|
body, _ := json.Marshal(meta)
|
|
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
|
`INSERT INTO paliad.project_events
|
|
(project_id, event_type, title, metadata, created_by, event_date, timeline_kind)
|
|
VALUES ($1, 'submission_exported', $2, $3::jsonb, $4, now(), 'custom_milestone')`,
|
|
*d.ProjectID, fmt.Sprintf("%s exportiert", strings.TrimSpace(ruleName)),
|
|
string(body), d.UserID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// writeSubmissionDraftServiceError maps draft-specific sentinels to
|
|
// the right HTTP status; falls back to the shared writeServiceError
|
|
// for everything else.
|
|
func writeSubmissionDraftServiceError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, services.ErrSubmissionDraftNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
case errors.Is(err, services.ErrSubmissionDraftNameTaken):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already in use"})
|
|
case errors.Is(err, services.ErrSubmissionRuleNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "submission rule not found"})
|
|
default:
|
|
writeServiceError(w, err)
|
|
}
|
|
}
|
|
|
|
// parseUUIDPath parses a UUID path parameter, writing a 400 and
|
|
// returning false on bad input.
|
|
func parseUUIDPath(w http.ResponseWriter, r *http.Request, key, label string) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(r.PathValue(key))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid " + label})
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// parseSubmissionCodePath pulls the {code} segment from the URL and
|
|
// validates it as a non-empty submission_code shape.
|
|
func parseSubmissionCodePath(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
code := strings.TrimSpace(r.PathValue("code"))
|
|
if code == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission code required"})
|
|
return "", false
|
|
}
|
|
return code, true
|
|
}
|
|
|
|
// userLang picks the user's preferred language with a German fallback.
|
|
func userLang(u *models.User) string {
|
|
if u != nil && u.Lang != "" {
|
|
return u.Lang
|
|
}
|
|
return "de"
|
|
}
|
|
|
|
// derefStringHandler safely dereferences a *string in handler-land.
|
|
// Mirrors the same helper in services/submission_vars.go (not exported
|
|
// from services to keep the package surface minimal).
|
|
func derefStringHandler(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|