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.
417 lines
16 KiB
Go
417 lines
16 KiB
Go
package handlers
|
|
|
|
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
|
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
|
|
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
|
|
// from format-only to the same merge engine the draft editor uses).
|
|
//
|
|
// Endpoints:
|
|
//
|
|
// GET /api/projects/{id}/submissions
|
|
// Lists every published filing rule across every active
|
|
// proceeding the platform knows about, joined with its
|
|
// proceeding_type so the frontend can group by proceeding.
|
|
// has_template flips per-row: true when a per-submission .docx
|
|
// is wired in submissionTemplateRegistry, false when the
|
|
// editor falls back to the universal HL Patents Style.
|
|
//
|
|
// POST /api/projects/{id}/submissions/{code}/generate
|
|
// Resolves the template through the cronus fallback chain
|
|
// (per-firm `submissionTemplateRegistry[code]` first, HL
|
|
// Patents Style as the universal fallback), builds a fresh
|
|
// variable bag via SubmissionVarsService.Build, and runs the
|
|
// SubmissionRenderer merge so every {{placeholder}} resolves
|
|
// to project state (or `[KEIN WERT: key]` for empties). Writes
|
|
// one paliad.system_audit_log row and streams the .docx as an
|
|
// attachment download. The HL Patents Style fallback has no
|
|
// placeholders today, so for codes without a per-firm template
|
|
// the renderer is a no-op on substitution but still runs the
|
|
// .dotm→.docx pre-pass.
|
|
//
|
|
// Visibility: every endpoint runs through ProjectService.GetByID
|
|
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
|
|
// convention as the rest of the project surfaces (no project-existence
|
|
// enumeration).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// submissionRenderTimeout caps a single generate request. .dotm fetch
|
|
// is from the in-process cache (sub-millisecond) and the convert step
|
|
// is a single zip round-trip; the timeout exists so a cold cache miss
|
|
// against Gitea surfaces quickly rather than letting the browser spin.
|
|
const submissionRenderTimeout = 30 * time.Second
|
|
|
|
// docxMime is the .docx Content-Type per the OOXML spec.
|
|
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
|
|
// hlPatentsStyleSlug names the universal style template inside the
|
|
// fileRegistry in files.go. Both surfaces (the /files download for
|
|
// Word's auto-update channel and this generator) share the same
|
|
// cache entry so a refresh through one path is visible to the other.
|
|
const hlPatentsStyleSlug = "hl-patents-style.dotm"
|
|
|
|
// submissionListEntry is one row in the Schriftsätze panel.
|
|
type submissionListEntry struct {
|
|
SubmissionCode string `json:"submission_code"`
|
|
Name string `json:"name"`
|
|
NameEN string `json:"name_en"`
|
|
EventType string `json:"event_type,omitempty"`
|
|
PrimaryParty string `json:"primary_party,omitempty"`
|
|
LegalSource string `json:"legal_source,omitempty"`
|
|
HasTemplate bool `json:"has_template"`
|
|
ProceedingCode string `json:"proceeding_code"`
|
|
ProceedingName string `json:"proceeding_name"`
|
|
ProceedingNameEN string `json:"proceeding_name_en"`
|
|
}
|
|
|
|
// submissionListResponse wraps the list with a project-level header.
|
|
//
|
|
// ProjectProceedingCode names the project's own proceeding so the
|
|
// frontend can pin its group to the top of the grouped catalog
|
|
// (t-paliad-242). nil when the project hasn't bound a proceeding yet.
|
|
type submissionListResponse struct {
|
|
ProjectID uuid.UUID `json:"project_id"`
|
|
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
|
ProjectProceedingCode *string `json:"project_proceeding_code,omitempty"`
|
|
Entries []submissionListEntry `json:"entries"`
|
|
}
|
|
|
|
// handleListProjectSubmissions returns every published filing rule
|
|
// across every active proceeding the platform knows about, joined with
|
|
// its proceeding_type so the Schriftsätze tab can group rows by
|
|
// proceeding (t-paliad-242 — m wants to see the entire catalog from any
|
|
// project, not just the rules for the project's own proceeding).
|
|
//
|
|
// Visibility is gated on the PROJECT (paliad.can_see_project via
|
|
// ProjectService.GetByID); the rules themselves are static reference
|
|
// data shared across the firm.
|
|
//
|
|
// has_template flips when a per-submission .docx is wired into
|
|
// submissionTemplateRegistry (files.go). When false, the universal HL
|
|
// Patents Style .dotm is the fallback — the editor (t-paliad-238)
|
|
// resolves both flavours transparently, so every row remains
|
|
// generatable and editable from the UI.
|
|
//
|
|
// Rows are sorted by (proceeding_code, submission_code) so the
|
|
// frontend's groupBy stays cheap and the order is stable.
|
|
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
resp := submissionListResponse{
|
|
ProjectID: projectID,
|
|
ProceedingTypeID: project.ProceedingTypeID,
|
|
Entries: []submissionListEntry{},
|
|
}
|
|
|
|
entries, ownCode, err := loadSubmissionCatalog(ctx, project.ProceedingTypeID)
|
|
if err != nil {
|
|
log.Printf("submissions: list submission catalog: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
|
return
|
|
}
|
|
resp.Entries = entries
|
|
resp.ProjectProceedingCode = ownCode
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleListSubmissionCatalog returns the same cross-proceeding catalog
|
|
// without a project context — used by the global /submissions/new
|
|
// picker (t-paliad-243). No project_proceeding_code is returned since
|
|
// the picker isn't pinned to one project.
|
|
func handleListSubmissionCatalog(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
|
|
entries, _, err := loadSubmissionCatalog(r.Context(), nil)
|
|
if err != nil {
|
|
log.Printf("submissions: list global submission catalog: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
|
}
|
|
|
|
// loadSubmissionCatalog runs the shared catalog query. When
|
|
// projectProceedingTypeID is non-nil, the returned ownCode points at
|
|
// that proceeding's code so the frontend can pin its group to the top;
|
|
// otherwise ownCode is nil.
|
|
func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([]submissionListEntry, *string, error) {
|
|
type catalogRow struct {
|
|
SubmissionCode string `db:"submission_code"`
|
|
Name string `db:"name"`
|
|
NameEN string `db:"name_en"`
|
|
EventType *string `db:"event_type"`
|
|
PrimaryParty *string `db:"primary_party"`
|
|
LegalSource *string `db:"legal_source"`
|
|
ProceedingID int `db:"proceeding_type_id"`
|
|
ProceedingCode string `db:"proceeding_code"`
|
|
ProceedingName string `db:"proceeding_name"`
|
|
ProceedingNameEN string `db:"proceeding_name_en"`
|
|
}
|
|
|
|
var rows []catalogRow
|
|
err := dbSvc.projects.DB().SelectContext(ctx, &rows,
|
|
`SELECT dr.submission_code AS submission_code,
|
|
dr.name AS name,
|
|
dr.name_en AS name_en,
|
|
dr.event_type AS event_type,
|
|
dr.primary_party AS primary_party,
|
|
dr.legal_source AS legal_source,
|
|
dr.proceeding_type_id AS proceeding_type_id,
|
|
pt.code AS proceeding_code,
|
|
pt.name AS proceeding_name,
|
|
pt.name_en AS proceeding_name_en
|
|
FROM paliad.deadline_rules dr
|
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
|
WHERE dr.is_active = true
|
|
AND dr.lifecycle_state = 'published'
|
|
AND dr.event_type = 'filing'
|
|
AND dr.submission_code IS NOT NULL
|
|
AND dr.submission_code <> ''
|
|
AND pt.is_active = true
|
|
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
entries := make([]submissionListEntry, 0, len(rows))
|
|
var ownCode *string
|
|
for _, row := range rows {
|
|
entry := submissionListEntry{
|
|
SubmissionCode: row.SubmissionCode,
|
|
Name: row.Name,
|
|
NameEN: row.NameEN,
|
|
HasTemplate: hasPerSubmissionTemplate(row.SubmissionCode),
|
|
ProceedingCode: row.ProceedingCode,
|
|
ProceedingName: row.ProceedingName,
|
|
ProceedingNameEN: row.ProceedingNameEN,
|
|
}
|
|
if row.EventType != nil {
|
|
entry.EventType = *row.EventType
|
|
}
|
|
if row.PrimaryParty != nil {
|
|
entry.PrimaryParty = *row.PrimaryParty
|
|
}
|
|
if row.LegalSource != nil {
|
|
entry.LegalSource = *row.LegalSource
|
|
}
|
|
entries = append(entries, entry)
|
|
if projectProceedingTypeID != nil && row.ProceedingID == *projectProceedingTypeID && ownCode == nil {
|
|
code := row.ProceedingCode
|
|
ownCode = &code
|
|
}
|
|
}
|
|
|
|
// If the project's proceeding has no filing rules of its own, fall
|
|
// back to a direct proceeding_types lookup so the frontend can still
|
|
// pin the right group even when the catalog ordering wouldn't have
|
|
// surfaced the code via a row.
|
|
if projectProceedingTypeID != nil && ownCode == nil {
|
|
var code string
|
|
if err := dbSvc.projects.DB().GetContext(ctx, &code,
|
|
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, *projectProceedingTypeID); err == nil && code != "" {
|
|
ownCode = &code
|
|
}
|
|
}
|
|
|
|
return entries, ownCode, nil
|
|
}
|
|
|
|
// hasPerSubmissionTemplate reports whether a per-submission .docx is
|
|
// wired in the fileRegistry (files.go). false means the editor falls
|
|
// back to the universal HL Patents Style — still renderable, still
|
|
// editable, but the UI may want to surface a "universal Vorlage"
|
|
// indicator. Read-only — no I/O, just a map lookup.
|
|
func hasPerSubmissionTemplate(submissionCode string) bool {
|
|
_, ok := submissionTemplateRegistry[submissionCode]
|
|
return ok
|
|
}
|
|
|
|
// handleGenerateProjectSubmission resolves the per-submission template
|
|
// (per-firm first, HL Patents Style fallback), builds a fresh variable
|
|
// bag from project state via SubmissionVarsService, runs the merge
|
|
// engine so every {{placeholder}} substitutes, writes one audit row,
|
|
// and streams the result. Pre-t-paliad-253 this handler ignored the
|
|
// per-firm registry and returned the bare HL Patents Style .dotm with
|
|
// no substitution — the "Generieren" button on the Schriftsätze tab
|
|
// therefore produced a generic firm-style .docx instead of a
|
|
// project-merged Klageerwiderung, which is what m noticed in
|
|
// m/paliad#84.
|
|
func handleGenerateProjectSubmission(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": "submissions not configured",
|
|
})
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
|
return
|
|
}
|
|
submissionCode := strings.TrimSpace(r.PathValue("code"))
|
|
if submissionCode == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission code required"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
|
defer cancel()
|
|
|
|
// One-shot /generate has no draft row to pull `language` from —
|
|
// accept `?language=de|en` as an explicit override (t-paliad-276)
|
|
// and otherwise fall back to the user's UI language.
|
|
user, _ := dbSvc.users.GetByID(ctx, uid)
|
|
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
|
|
if lang != "de" && lang != "en" {
|
|
lang = userLang(user)
|
|
}
|
|
|
|
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
|
|
if err != nil {
|
|
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
|
return
|
|
}
|
|
|
|
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{
|
|
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
|
})
|
|
return
|
|
}
|
|
// ErrNotVisible / project ErrNotFound from the visibility gate
|
|
// surface through writeServiceError as 404, matching the rest
|
|
// of the project surfaces.
|
|
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
|
|
|
// Audit write is best-effort with a background context so the
|
|
// download still succeeds if the DB races. Audit failure here only
|
|
// affects the system_audit_log feed — never the user's response.
|
|
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancelBG()
|
|
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
|
|
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, 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("submissions: response write failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
|
}
|
|
}
|
|
|
|
// submissionFileName produces the user-facing download name per
|
|
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
|
// Empty case_number drops the segment entirely (no fallback hash —
|
|
// the lawyer can rename if the project lacks an Aktenzeichen).
|
|
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
|
|
// so the file lands cleanly on legacy SMB shares.
|
|
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
|
|
day := time.Now()
|
|
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
|
day = day.In(loc)
|
|
}
|
|
ruleName := strings.TrimSpace(rule.Name)
|
|
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
|
ruleName = strings.TrimSpace(rule.NameEN)
|
|
}
|
|
if ruleName == "" {
|
|
ruleName = "submission"
|
|
}
|
|
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
|
|
caseNo := ""
|
|
if project != nil && project.CaseNumber != nil {
|
|
caseNo = strings.TrimSpace(*project.CaseNumber)
|
|
}
|
|
if caseNo != "" {
|
|
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
|
|
}
|
|
parts = append(parts, day.Format("2006-01-02"))
|
|
return strings.Join(parts, "-") + ".docx"
|
|
}
|
|
|
|
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
|
|
// generation. event_type='submission.generated', scope='project',
|
|
// scope_root=project_id. Metadata is intentionally small per Slice 1:
|
|
// {submission_code, rule_name, filename} — enough for a reviewer to
|
|
// reconstruct which template was offered to which project without
|
|
// over-baking the audit shape.
|
|
func writeSubmissionAuditRow(ctx context.Context, user *models.User, projectID uuid.UUID, submissionCode, ruleName, filename string) error {
|
|
meta := map[string]any{
|
|
"submission_code": submissionCode,
|
|
"rule_name": ruleName,
|
|
"filename": filename,
|
|
}
|
|
body, _ := json.Marshal(meta)
|
|
var (
|
|
actorID any
|
|
actorEmail string
|
|
)
|
|
if user != nil {
|
|
actorID = user.ID
|
|
actorEmail = user.Email
|
|
}
|
|
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
|
`INSERT INTO paliad.system_audit_log
|
|
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
|
VALUES ('submission.generated', $1, $2, 'project', $3, $4::jsonb)`,
|
|
actorID, actorEmail, projectID.String(), string(body),
|
|
)
|
|
return err
|
|
}
|