Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.
Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:
per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm
The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.
Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
.docx generator mirroring gen-demo-submission-template but with a
code-agnostic body (no Klageerwiderung "I./II./III." structure, a
single [Schriftsatztext] block the lawyer replaces). One run per
placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
between fetchSubmissionTemplateBytes (per-firm code) and
fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
the final fallback for resilience if mWorkRepo is unreachable.
The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.
Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
1103 lines
38 KiB
Go
1103 lines
38 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"`
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// 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}
|
|
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)
|
|
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)
|
|
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"`
|
|
// 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"`
|
|
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.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}
|
|
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)
|
|
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, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
|
|
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
|
|
}
|
|
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view.PreviewHTML = html
|
|
return view, nil
|
|
}
|
|
|
|
// resolveSubmissionTemplate returns the .docx bytes for the given
|
|
// submission code. Lookup order matches the cronus design fallback chain
|
|
// §8 plus the t-paliad-259 universal-skeleton slot:
|
|
//
|
|
// 1. per-firm per-submission_code template registered in
|
|
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
|
|
// specific structure plus the full variable bag.
|
|
// 2. universal _skeleton.docx — same variable bag, no submission_code-
|
|
// specific prose. Catches every code without a dedicated template
|
|
// so the editor preview / generate flow still has variables to
|
|
// substitute instead of falling through to the bare letterhead.
|
|
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
|
|
// placeholders. Final fallback when even the skeleton is unreachable
|
|
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
|
|
// for resilience.
|
|
//
|
|
// The returned SHA is the cache entry's commit SHA so the export audit
|
|
// row can record provenance.
|
|
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
|
|
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
|
|
return nil, "", err
|
|
} else if found {
|
|
return data, sha, nil
|
|
}
|
|
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
|
return data, sha, nil
|
|
} else {
|
|
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
|
|
}
|
|
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
sha := hlPatentsStyleSHA()
|
|
return bytes, sha, nil
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
return submissionDraftJSON{
|
|
ID: d.ID,
|
|
ProjectID: d.ProjectID,
|
|
SubmissionCode: d.SubmissionCode,
|
|
UserID: d.UserID,
|
|
Name: d.Name,
|
|
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
|
|
}
|