diff --git a/internal/handlers/submissions.go b/internal/handlers/submissions.go index 64cdbe6..9dc1256 100644 --- a/internal/handlers/submissions.go +++ b/internal/handlers/submissions.go @@ -2,7 +2,8 @@ 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). +// to the full cross-proceeding catalog; t-paliad-253 promoted /generate +// from format-only to the same merge engine the draft editor uses). // // Endpoints: // @@ -15,17 +16,17 @@ package handlers // editor falls back to the universal HL Patents Style. // // POST /api/projects/{id}/submissions/{code}/generate -// Fetches the cached HL Patents Style .dotm (same proxy used -// by /files/hl-patents-style.dotm), converts it to a clean -// .docx via services.ConvertDotmToDocx, writes one -// paliad.system_audit_log row, and streams the result as an -// attachment download. -// -// No variable substitution, no per-submission templates, no -// project_events/documents writes. Those layers are deferred to a -// future "merge engine" slice; today's generator hands the lawyer a -// clean .docx of the firm style and lets them edit and save under -// their own filename. +// 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 @@ -265,10 +266,16 @@ func hasPerSubmissionTemplate(submissionCode string) bool { return ok } -// handleGenerateProjectSubmission fetches the universal HL Patents -// Style .dotm, converts it to a clean .docx, writes one audit row, and -// streams the result. No variable substitution; the bytes that go down -// the wire are the firm style template with macros stripped. +// 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 @@ -277,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) { 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"}) @@ -291,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout) defer cancel() - project, err := dbSvc.projects.GetByID(ctx, uid, projectID) + tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode) 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 } - rule, err := loadPublishedRuleByCode(ctx, submissionCode) + docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes) if err != nil { - if errors.Is(err, errRuleNotFound) { + 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 } - log.Printf("submissions: load rule %q: %v", submissionCode, err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"}) + // 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 } - dotm, err := fetchHLPatentsStyleBytes(ctx) - 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) + 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, 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) } @@ -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 // design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx. // Empty case_number drops the segment entirely (no fallback hash — diff --git a/internal/services/submission_draft_service.go b/internal/services/submission_draft_service.go index e33cd36..9803672 100644 --- a/internal/services/submission_draft_service.go +++ b/internal/services/submission_draft_service.go @@ -519,6 +519,34 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr 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. // Called by every fetch path so the caller sees a populated Variables. func (d *SubmissionDraft) decodeVariables() error { diff --git a/scripts/gen-demo-submission-template/main.go b/scripts/gen-demo-submission-template/main.go index 55b5f8f..0828890 100644 --- a/scripts/gen-demo-submission-template/main.go +++ b/scripts/gen-demo-submission-template/main.go @@ -232,12 +232,15 @@ func buildDocumentXML() string { // English-locale exercise — lets the lawyer verify the EN long-form // 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)") 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, "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, "Today (bare alias): {{today}}") b.WriteString(``) return b.String()