fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.
The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.
Fix:
- `internal/services/submission_draft_service.go` — add
`RenderProjectSubmission(ctx, userID, projectID, submissionCode,
templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
no-saved-draft path. Returns the merged bytes plus the resolved
SubmissionVarsResult (rule, project, user, lang) so the handler can
derive filename + audit metadata without a second DB round-trip.
- `internal/handlers/submissions.go` — rewrite
`handleGenerateProjectSubmission` to resolve the template via
`resolveSubmissionTemplate` (per-firm slug → HL Patents Style
fallback, same as the editor draft) and run the new service method.
Visibility / rule-not-found semantics route through
`SubmissionVarsService` errors so the gate behavior matches every
other project endpoint. Removed `loadPublishedRuleByCode` and
`errRuleNotFound` — both were only used by the old handler.
- `scripts/gen-demo-submission-template/main.go` + the regenerated
`de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
exercise the bare `{{today}}` alias too. The demo template covers
every one of the 48 keys SubmissionVarsService can resolve (firm 2,
today 4, user 3, project 18, parties 6, rule 8, deadline 7).
The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.
Build + vet + tests clean (go test ./internal/...; bun run build).
This commit is contained in:
@@ -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 —
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
|
||||
Reference in New Issue
Block a user