Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84)
This commit is contained in:
@@ -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 —
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user