Files
paliad/internal/handlers/submission_drafts.go
mAi 669764e86f mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).

Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
  CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
  preserving every legacy draft's behaviour byte-for-byte.

Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
  uses it when set; falls back to user.Lang otherwise — Slice 1's
  format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
  through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
  outside {de,en}. Project-scoped + global PATCH endpoints both
  surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
  predecessor. Returns the matched tier (per_code_lang / per_code /
  skeleton_lang / skeleton / letterhead) so the editor knows whether
  to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
  alongside the DE one; per-code EN variants land in a parallel
  submissionTemplateENRegistry (empty for now — EN templates land per
  HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
  `?language=de|en` query override (one-shot path, no draft row to
  pull the column from); defaults to the user's UI lang.

Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
  Switching the radio PATCHes `language` and the server returns the
  freshly-resolved bag + preview HTML so the lawyer sees EN values
  immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
  sprachspezifische Vorlage)") shows when the resolved tier doesn't
  match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.

Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
  rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.

Build hygiene: go build/vet/test clean; bun run build clean.
2026-05-25 16:39:29 +02:00

1172 lines
41 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"`
}
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"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
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"`
Language *string `json:"language,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
// 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, Language: input.Language}
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
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
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); 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)
}
}
// 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
}
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"`
}
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
// Detect whether "project_id" was 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"]
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, Language: in.Language}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
}
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)
}
// 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
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
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); 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,
}
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 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). Lookup order, t-paliad-276:
//
// 1. per-firm per-(code,lang) template — e.g. de.inf.lg.erwidg.en.docx
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline
// 3. universal _skeleton.{lang}.docx — language-matched skeleton
// 4. universal _skeleton.docx — DE-baked skeleton (fallback for EN
// drafts when no EN skeleton is authored yet)
// 5. universal HL Patents Style .dotm — macro-only letterhead, last
// resort when even the skeleton is unreachable.
//
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the requested
// language so it can surface a fallback 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 + 4. skeleton (lang-matched, else DE)
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil {
tier := tplTierSkeleton
if langMatched {
tier = tplTierSkeletonLang
}
return data, sha, tier, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 5. 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{}
}
lang := d.Language
if lang == "" {
lang = "de"
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
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) error {
meta := map[string]any{
"submission_code": d.SubmissionCode,
"draft_id": d.ID.String(),
"draft_name": d.Name,
"filename": filename,
"template_sha": templateSHA,
}
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
}