Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84)

This commit is contained in:
mAi
2026-05-25 13:55:14 +02:00
3 changed files with 73 additions and 87 deletions

View File

@@ -2,7 +2,8 @@ package handlers
// Submission generator HTTP layer (t-paliad-230 — format-only scope // Submission generator HTTP layer (t-paliad-230 — format-only scope
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint // reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
// to the full cross-proceeding catalog). // to the full cross-proceeding catalog; t-paliad-253 promoted /generate
// from format-only to the same merge engine the draft editor uses).
// //
// Endpoints: // Endpoints:
// //
@@ -15,17 +16,17 @@ package handlers
// editor falls back to the universal HL Patents Style. // editor falls back to the universal HL Patents Style.
// //
// POST /api/projects/{id}/submissions/{code}/generate // POST /api/projects/{id}/submissions/{code}/generate
// Fetches the cached HL Patents Style .dotm (same proxy used // Resolves the template through the cronus fallback chain
// by /files/hl-patents-style.dotm), converts it to a clean // (per-firm `submissionTemplateRegistry[code]` first, HL
// .docx via services.ConvertDotmToDocx, writes one // Patents Style as the universal fallback), builds a fresh
// paliad.system_audit_log row, and streams the result as an // variable bag via SubmissionVarsService.Build, and runs the
// attachment download. // SubmissionRenderer merge so every {{placeholder}} resolves
// // to project state (or `[KEIN WERT: key]` for empties). Writes
// No variable substitution, no per-submission templates, no // one paliad.system_audit_log row and streams the .docx as an
// project_events/documents writes. Those layers are deferred to a // attachment download. The HL Patents Style fallback has no
// future "merge engine" slice; today's generator hands the lawyer a // placeholders today, so for codes without a per-firm template
// clean .docx of the firm style and lets them edit and save under // the renderer is a no-op on substitution but still runs the
// their own filename. // .dotm→.docx pre-pass.
// //
// Visibility: every endpoint runs through ProjectService.GetByID // Visibility: every endpoint runs through ProjectService.GetByID
// (paliad.can_see_project gate). Unauthorised callers get 404 — same // (paliad.can_see_project gate). Unauthorised callers get 404 — same
@@ -265,10 +266,16 @@ func hasPerSubmissionTemplate(submissionCode string) bool {
return ok return ok
} }
// handleGenerateProjectSubmission fetches the universal HL Patents // handleGenerateProjectSubmission resolves the per-submission template
// Style .dotm, converts it to a clean .docx, writes one audit row, and // (per-firm first, HL Patents Style fallback), builds a fresh variable
// streams the result. No variable substitution; the bytes that go down // bag from project state via SubmissionVarsService, runs the merge
// the wire are the firm style template with macros stripped. // 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) { func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) { if !requireDB(w) {
return return
@@ -277,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submissions not configured",
})
return
}
projectID, err := uuid.Parse(r.PathValue("id")) projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
@@ -291,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout) ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel() defer cancel()
project, err := dbSvc.projects.GetByID(ctx, uid, projectID) tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
if err != nil { if err != nil {
writeServiceError(w, err) 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 return
} }
rule, err := loadPublishedRuleByCode(ctx, submissionCode) docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
if err != nil { if err != nil {
if errors.Is(err, errRuleNotFound) { if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{ writeJSON(w, http.StatusNotFound, map[string]string{
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode), "error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
}) })
return return
} }
log.Printf("submissions: load rule %q: %v", submissionCode, err) // ErrNotVisible / project ErrNotFound from the visibility gate
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"}) // 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 return
} }
dotm, err := fetchHLPatentsStyleBytes(ctx) filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
if err != nil {
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "template upstream unreachable",
})
return
}
docx, err := services.ConvertDotmToDocx(dotm)
if err != nil {
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "convert failed",
})
return
}
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil {
log.Printf("submissions: load user %s: %v", uid, err)
}
lang := "de"
if user != nil && user.Lang != "" {
lang = user.Lang
}
filename := submissionFileName(rule, project, lang)
// Audit write is best-effort with a background context so the // Audit write is best-effort with a background context so the
// download still succeeds if the DB races. Audit failure here only // download still succeeds if the DB races. Audit failure here only
// affects the system_audit_log feed — never the user's response. // affects the system_audit_log feed — never the user's response.
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second) bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG() defer cancelBG()
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil { 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) log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
} }
@@ -356,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
} }
} }
// errRuleNotFound is the sentinel for "no published rule with that
// submission_code" — distinguished from a generic DB error so the
// handler returns 404 instead of 500.
var errRuleNotFound = errors.New("submission rule not found")
// loadPublishedRuleByCode fetches the rule the user requested. Only
// published+active rows resolve; drafts and archived rules never feed
// a real submission.
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, errRuleNotFound
}
var rule models.DeadlineRule
err := dbSvc.projects.DB().GetContext(ctx, &rule,
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value, duration_unit,
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at, lifecycle_state
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
ORDER BY sequence_order
LIMIT 1`, submissionCode)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, errRuleNotFound
}
return nil, err
}
return &rule, nil
}
// submissionFileName produces the user-facing download name per // submissionFileName produces the user-facing download name per
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx. // design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
// Empty case_number drops the segment entirely (no fallback hash — // Empty case_number drops the segment entirely (no fallback hash —

View File

@@ -519,6 +519,34 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
return out, resolved, nil return out, resolved, nil
} }
// RenderProjectSubmission renders the given .docx template with a fresh
// variable bag for (user, project, submissionCode). No lawyer overrides
// — the output reflects exactly what SubmissionVarsService resolves
// from project state. Used by the one-click /api/projects/{id}/
// submissions/{code}/generate path which has no saved draft row.
//
// Returns the merged bytes plus the resolved bag (for audit row + file
// naming). Visibility is enforced by SubmissionVarsService.Build via
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
})
if err != nil {
return nil, nil, err
}
out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
if err != nil {
return nil, nil, err
}
return out, resolved, nil
}
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap. // decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
// Called by every fetch path so the caller sees a populated Variables. // Called by every fetch path so the caller sees a populated Variables.
func (d *SubmissionDraft) decodeVariables() error { func (d *SubmissionDraft) decodeVariables() error {

View File

@@ -232,12 +232,15 @@ func buildDocumentXML() string {
// English-locale exercise — lets the lawyer verify the EN long-form // English-locale exercise — lets the lawyer verify the EN long-form
// date and EN proceeding name resolve correctly when the user's // date and EN proceeding name resolve correctly when the user's
// preference is en. // preference is en. Also exercises the bare {{today}} alias
// (identical to {{today.iso}}; included so every key the variable
// bag carries appears at least once in this demo template).
heading2(&b, "Locale-aware variants (DEMO)") heading2(&b, "Locale-aware variants (DEMO)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}") plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}") plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}") plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}") plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
plain(&b, "Today (bare alias): {{today}}")
b.WriteString(`</w:body></w:document>`) b.WriteString(`</w:body></w:document>`)
return b.String() return b.String()