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).
408 lines
16 KiB
Go
408 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()
|
|
|
|
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
|
|
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, 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
|
|
}
|