Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).
Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
preserving every legacy draft's behaviour byte-for-byte.
Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
uses it when set; falls back to user.Lang otherwise — Slice 1's
format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
outside {de,en}. Project-scoped + global PATCH endpoints both
surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
predecessor. Returns the matched tier (per_code_lang / per_code /
skeleton_lang / skeleton / letterhead) so the editor knows whether
to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
alongside the DE one; per-code EN variants land in a parallel
submissionTemplateENRegistry (empty for now — EN templates land per
HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
`?language=de|en` query override (one-shot path, no draft row to
pull the column from); defaults to the user's UI lang.
Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
Switching the radio PATCHes `language` and the server returns the
freshly-resolved bag + preview HTML so the lawyer sees EN values
immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
sprachspezifische Vorlage)") shows when the resolved tier doesn't
match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.
Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.
Build hygiene: go build/vet/test clean; bun run build clean.
611 lines
22 KiB
Go
611 lines
22 KiB
Go
package services
|
|
|
|
// Submission draft service — CRUD over paliad.submission_drafts plus
|
|
// the render+export entry points that combine the variable bag, lawyer
|
|
// overrides, and template fetch into a .docx or HTML preview
|
|
// (t-paliad-238 Slice A, design doc
|
|
// docs/design-submission-page-2026-05-22.md §5.2).
|
|
//
|
|
// Each draft is owned by one user; multiple drafts per (project,
|
|
// submission_code, user_id) are supported via the `name` column. The
|
|
// override semantics are explicit:
|
|
//
|
|
// variables = {"project.case_number": "2 O 999/25"} → use this value
|
|
// variables = {"project.case_number": ""} → force [KEIN WERT: …]
|
|
// key absent → fall back to bag
|
|
//
|
|
// Visibility flows through ProjectService.GetByID — every read and
|
|
// write gates on paliad.can_see_project. RLS in the DB enforces the
|
|
// owner-scoped UPDATE/DELETE constraint independently of the Go layer.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// SubmissionDraft mirrors a row in paliad.submission_drafts.
|
|
//
|
|
// ProjectID is nullable since t-paliad-243 — a draft started from the
|
|
// global /submissions/new picker without picking a project is private
|
|
// to its creator and carries an empty variable bag (no project /
|
|
// parties / deadline state to resolve). All callers must check for nil
|
|
// before treating it as a uuid.
|
|
type SubmissionDraft struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
|
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
// Language is the output language for the generated .docx — 'de' or
|
|
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
|
|
// fallback chain) and language-aware variable resolution
|
|
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
|
|
Language string `db:"language" json:"language"`
|
|
VariablesRaw []byte `db:"variables" json:"-"`
|
|
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
|
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
|
|
// Variables is the decoded overrides map; populated on read by the
|
|
// service so callers don't have to unmarshal manually.
|
|
Variables PlaceholderMap `json:"variables"`
|
|
}
|
|
|
|
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
|
// the render/preview/export entry points the handler layer calls.
|
|
type SubmissionDraftService struct {
|
|
db *sqlx.DB
|
|
projects *ProjectService
|
|
vars *SubmissionVarsService
|
|
renderer *SubmissionRenderer
|
|
}
|
|
|
|
// NewSubmissionDraftService wires the service.
|
|
func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *SubmissionVarsService, renderer *SubmissionRenderer) *SubmissionDraftService {
|
|
return &SubmissionDraftService{
|
|
db: db,
|
|
projects: projects,
|
|
vars: vars,
|
|
renderer: renderer,
|
|
}
|
|
}
|
|
|
|
// DraftPatch carries optional fields for Update. nil pointer = "no
|
|
// change"; non-nil = "set to this". Variables is replace-semantics —
|
|
// the lawyer's sidebar sends the full map every save.
|
|
//
|
|
// ProjectID uses a two-level pointer (t-paliad-243) so we can encode
|
|
// the three operations the global drafts flow needs:
|
|
//
|
|
// patch.ProjectID == nil → no change
|
|
// *patch.ProjectID == nil → detach (re-set to NULL)
|
|
// **patch.ProjectID → attach (assign a project)
|
|
//
|
|
// The detach path stays as scope for symmetry with attach even though
|
|
// the current frontend only exposes attach.
|
|
type DraftPatch struct {
|
|
Name *string
|
|
Variables *PlaceholderMap
|
|
ProjectID **uuid.UUID
|
|
// Language sets the output language. Valid values: "de", "en".
|
|
// Anything else returns ErrInvalidInput. t-paliad-276.
|
|
Language *string
|
|
}
|
|
|
|
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
|
// visible to this user". Maps to 404 in the handler.
|
|
var ErrSubmissionDraftNotFound = errors.New("submission draft: not found")
|
|
|
|
// ErrSubmissionDraftNameTaken is the sentinel for duplicate names per
|
|
// (project, submission_code, user). Maps to 409 in the handler.
|
|
var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already taken")
|
|
|
|
// draftColumns is the canonical select list — kept in one place so
|
|
// every fetch stays in sync.
|
|
const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
|
variables, last_exported_at, last_exported_sha,
|
|
created_at, updated_at`
|
|
|
|
// List returns every draft for (project, submission_code, user)
|
|
// ordered by updated_at DESC. Visibility flows through projects.GetByID.
|
|
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error) {
|
|
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
var rows []SubmissionDraft
|
|
err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT `+draftColumns+`
|
|
FROM paliad.submission_drafts
|
|
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
|
ORDER BY updated_at DESC`,
|
|
projectID, submissionCode, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list submission drafts: %w", err)
|
|
}
|
|
for i := range rows {
|
|
if err := rows[i].decodeVariables(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// DraftWithProject is the row shape for the global /submissions index —
|
|
// a draft joined with the minimal project metadata the table needs.
|
|
// Visibility is gated by paliad.can_see_project in the SELECT itself.
|
|
//
|
|
// ProjectTitle / ProjectReference are pointer-nullable since
|
|
// t-paliad-243 — project-less drafts surface in the same list with a
|
|
// NULL project ref, and the frontend renders them with a dedicated
|
|
// "kein Projekt" label.
|
|
type DraftWithProject struct {
|
|
SubmissionDraft
|
|
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
|
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
|
}
|
|
|
|
// ListAllForUser returns every draft the user owns across visible
|
|
// projects PLUS every project-less draft the user owns, ordered by
|
|
// updated_at DESC. LEFT JOIN on paliad.projects keeps project-less rows
|
|
// in the result set; the WHERE clause permits project_id IS NULL or a
|
|
// visible can_see_project hit, so a draft on a project the user no
|
|
// longer has access to is silently dropped.
|
|
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
|
|
var rows []DraftWithProject
|
|
err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
|
d.variables, d.last_exported_at, d.last_exported_sha,
|
|
d.created_at, d.updated_at,
|
|
p.title AS project_title,
|
|
p.reference AS project_reference
|
|
FROM paliad.submission_drafts d
|
|
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
|
WHERE d.user_id = $1
|
|
AND (
|
|
d.project_id IS NULL
|
|
OR paliad.can_see_project(d.project_id)
|
|
)
|
|
ORDER BY d.updated_at DESC`,
|
|
userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
|
|
}
|
|
for i := range rows {
|
|
if err := rows[i].decodeVariables(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// Get returns a single draft by id, gated on project visibility AND
|
|
// owner-only — the caller can only fetch drafts they own. RLS in the
|
|
// DB enforces this independently; the Go check makes the 404 semantics
|
|
// explicit at the service boundary.
|
|
//
|
|
// A project-less draft (ProjectID == nil) skips the can_see_project
|
|
// gate — the owner-only constraint is the entire visibility check.
|
|
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
|
|
var d SubmissionDraft
|
|
err := s.db.GetContext(ctx, &d,
|
|
`SELECT `+draftColumns+`
|
|
FROM paliad.submission_drafts
|
|
WHERE id = $1 AND user_id = $2`,
|
|
draftID, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrSubmissionDraftNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get submission draft: %w", err)
|
|
}
|
|
if d.ProjectID != nil {
|
|
if _, err := s.projects.GetByID(ctx, userID, *d.ProjectID); err != nil {
|
|
// Project no longer visible → behave as not-found rather than
|
|
// leaking the draft's existence. ON DELETE CASCADE keeps this
|
|
// rare in practice.
|
|
if errors.Is(err, ErrNotVisible) {
|
|
return nil, ErrSubmissionDraftNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := d.decodeVariables(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// EnsureLatest returns the user's most-recently-updated draft for
|
|
// (project, submission_code). Creates "Entwurf 1" / "Draft 1" if none
|
|
// exists. Idempotent on repeat calls — once a draft exists, EnsureLatest
|
|
// always returns the freshest one rather than spawning new rows.
|
|
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
|
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
var d SubmissionDraft
|
|
err := s.db.GetContext(ctx, &d,
|
|
`SELECT `+draftColumns+`
|
|
FROM paliad.submission_drafts
|
|
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
|
ORDER BY updated_at DESC
|
|
LIMIT 1`,
|
|
projectID, submissionCode, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return s.Create(ctx, userID, &projectID, submissionCode, lang)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
|
|
}
|
|
if err := d.decodeVariables(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// Create makes a new draft with an auto-incremented "Entwurf N" name
|
|
// ("Draft N" for English locale). Lawyer can rename via Update.
|
|
//
|
|
// A nil projectID creates a project-less draft (t-paliad-243); the
|
|
// visibility check is skipped — the caller is the owner and the row is
|
|
// private to them.
|
|
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
|
if projectID != nil {
|
|
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Seed the new draft's output language from the user's UI lang so
|
|
// the editor opens in the language the lawyer is already working in.
|
|
// Anything other than "en" normalizes to "de" — matches the DB CHECK
|
|
// constraint and the project's primary-language default.
|
|
draftLang := normalizeDraftLanguage(lang)
|
|
var d SubmissionDraft
|
|
err = s.db.GetContext(ctx, &d,
|
|
`INSERT INTO paliad.submission_drafts
|
|
(project_id, submission_code, user_id, name, language)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING `+draftColumns,
|
|
projectID, submissionCode, userID, name, draftLang)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create submission draft: %w", err)
|
|
}
|
|
if err := d.decodeVariables(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
|
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
|
// suffix if two callers race; the unique constraint on the table is
|
|
// the final guard.
|
|
//
|
|
// A nil projectID scopes the search to the user's project-less drafts
|
|
// for this submission_code — matches the row-uniqueness contract on
|
|
// the DB side (project_id, submission_code, user_id, name) where
|
|
// project_id IS NULL is its own equivalence class.
|
|
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
|
prefix := "Entwurf"
|
|
if strings.EqualFold(lang, "en") {
|
|
prefix = "Draft"
|
|
}
|
|
var names []string
|
|
var err error
|
|
if projectID == nil {
|
|
err = s.db.SelectContext(ctx, &names,
|
|
`SELECT name FROM paliad.submission_drafts
|
|
WHERE project_id IS NULL AND submission_code = $1 AND user_id = $2`,
|
|
submissionCode, userID)
|
|
} else {
|
|
err = s.db.SelectContext(ctx, &names,
|
|
`SELECT name FROM paliad.submission_drafts
|
|
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3`,
|
|
*projectID, submissionCode, userID)
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("scan existing draft names: %w", err)
|
|
}
|
|
highest := 0
|
|
for _, n := range names {
|
|
var idx int
|
|
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
|
highest = idx
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s %d", prefix, highest+1), nil
|
|
}
|
|
|
|
// Update patches the draft. Variables is replace-semantics — pass the
|
|
// full map. Name patches go through a uniqueness check to surface
|
|
// ErrSubmissionDraftNameTaken cleanly instead of a raw constraint
|
|
// violation.
|
|
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error) {
|
|
existing, err := s.Get(ctx, userID, draftID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
setParts := []string{}
|
|
args := []any{}
|
|
idx := 1
|
|
|
|
if patch.Name != nil {
|
|
newName := strings.TrimSpace(*patch.Name)
|
|
if newName == "" {
|
|
return nil, ErrInvalidInput
|
|
}
|
|
if newName != existing.Name {
|
|
// Pre-check for the unique constraint so we can return a
|
|
// typed error instead of a raw PG conflict. NULL project_id
|
|
// is its own equivalence class in the unique index (NULLs
|
|
// don't collide), so the no-project flow checks `IS NULL`.
|
|
var dup int
|
|
var qErr error
|
|
if existing.ProjectID == nil {
|
|
qErr = s.db.GetContext(ctx, &dup,
|
|
`SELECT COUNT(*) FROM paliad.submission_drafts
|
|
WHERE project_id IS NULL AND submission_code = $1
|
|
AND user_id = $2 AND name = $3 AND id <> $4`,
|
|
existing.SubmissionCode, userID, newName, draftID)
|
|
} else {
|
|
qErr = s.db.GetContext(ctx, &dup,
|
|
`SELECT COUNT(*) FROM paliad.submission_drafts
|
|
WHERE project_id = $1 AND submission_code = $2
|
|
AND user_id = $3 AND name = $4 AND id <> $5`,
|
|
*existing.ProjectID, existing.SubmissionCode, userID, newName, draftID)
|
|
}
|
|
if qErr != nil {
|
|
return nil, fmt.Errorf("check name uniqueness: %w", qErr)
|
|
}
|
|
if dup > 0 {
|
|
return nil, ErrSubmissionDraftNameTaken
|
|
}
|
|
}
|
|
setParts = append(setParts, fmt.Sprintf("name = $%d", idx))
|
|
args = append(args, newName)
|
|
idx++
|
|
}
|
|
|
|
if patch.Variables != nil {
|
|
raw, err := json.Marshal(*patch.Variables)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal variables: %w", err)
|
|
}
|
|
setParts = append(setParts, fmt.Sprintf("variables = $%d::jsonb", idx))
|
|
args = append(args, string(raw))
|
|
idx++
|
|
}
|
|
|
|
if patch.ProjectID != nil {
|
|
newPID := *patch.ProjectID // *uuid.UUID — nil means detach
|
|
if newPID != nil {
|
|
// Caller must be able to see the project they're attaching
|
|
// the draft to; same gate as Create.
|
|
if _, err := s.projects.GetByID(ctx, userID, *newPID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
setParts = append(setParts, fmt.Sprintf("project_id = $%d", idx))
|
|
args = append(args, newPID)
|
|
idx++
|
|
}
|
|
|
|
if patch.Language != nil {
|
|
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
|
|
if newLang != "de" && newLang != "en" {
|
|
return nil, ErrInvalidInput
|
|
}
|
|
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
|
|
args = append(args, newLang)
|
|
idx++
|
|
}
|
|
|
|
if len(setParts) == 0 {
|
|
return existing, nil
|
|
}
|
|
|
|
args = append(args, draftID, userID)
|
|
q := fmt.Sprintf(
|
|
`UPDATE paliad.submission_drafts
|
|
SET %s
|
|
WHERE id = $%d AND user_id = $%d
|
|
RETURNING %s`,
|
|
strings.Join(setParts, ", "), idx, idx+1, draftColumns,
|
|
)
|
|
|
|
var d SubmissionDraft
|
|
err = s.db.GetContext(ctx, &d, q, args...)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrSubmissionDraftNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update submission draft: %w", err)
|
|
}
|
|
if err := d.decodeVariables(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// Delete removes the draft. Visibility-gated via Get; the DELETE itself
|
|
// is owner-scoped (user_id = caller).
|
|
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error {
|
|
if _, err := s.Get(ctx, userID, draftID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM paliad.submission_drafts WHERE id = $1 AND user_id = $2`,
|
|
draftID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete submission draft: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkExported updates the last_exported_* columns after a successful
|
|
// export. Background-context safe.
|
|
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
|
|
var sha any
|
|
if templateSHA != "" {
|
|
sha = templateSHA
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE paliad.submission_drafts
|
|
SET last_exported_at = now(),
|
|
last_exported_sha = $1
|
|
WHERE id = $2`,
|
|
sha, draftID)
|
|
if err != nil {
|
|
return fmt.Errorf("mark submission draft exported: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildRenderBag composes the placeholder map for a draft — pulls
|
|
// project/parties/rule/deadline state from SubmissionVarsService, then
|
|
// layers the lawyer's overrides on top.
|
|
//
|
|
// Override semantics:
|
|
//
|
|
// variables[key] = "" → delete the key (force [KEIN WERT: key])
|
|
// variables[key] = "X" → bag[key] = "X"
|
|
// key absent → bag[key] unchanged (falls back to resolved value)
|
|
//
|
|
// Returns the final PlaceholderMap along with the SubmissionVarsResult
|
|
// so callers (export, file naming) get the resolved entities too. A
|
|
// project-less draft (ProjectID == nil, t-paliad-243) skips project /
|
|
// parties / deadline lookups — the resolved bag carries only the
|
|
// user-independent variables (firm, today) plus the user.* group; the
|
|
// lawyer's overrides fill the rest.
|
|
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
|
|
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
|
UserID: draft.UserID,
|
|
ProjectID: draft.ProjectID,
|
|
SubmissionCode: draft.SubmissionCode,
|
|
// The draft's language overrides the user's UI lang — the lawyer
|
|
// can author an EN draft in a DE-UI session and vice versa
|
|
// (t-paliad-276). Empty / unknown falls back to "de".
|
|
Lang: normalizeDraftLanguage(draft.Language),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
bag := PlaceholderMap{}
|
|
maps.Copy(bag, resolved.Placeholders)
|
|
for k, v := range draft.Variables {
|
|
if v == "" {
|
|
delete(bag, k)
|
|
continue
|
|
}
|
|
bag[k] = v
|
|
}
|
|
return bag, resolved, nil
|
|
}
|
|
|
|
// RenderPreview returns the HTML preview of the merged document body
|
|
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
|
|
// with <strong>/<em> spans for runs flagged bold/italic.
|
|
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
|
|
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
|
}
|
|
|
|
// Export renders the merged .docx for download. Returns the bytes, the
|
|
// resolved bag (for audit row + file naming), and the variables result
|
|
// (lang, rule.Name, project.case_number). Callers wire MarkExported and
|
|
// the audit writes.
|
|
func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
|
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
out, err := s.renderer.Render(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
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, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
|
pid := projectID
|
|
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
|
UserID: userID,
|
|
ProjectID: &pid,
|
|
SubmissionCode: submissionCode,
|
|
Lang: normalizeDraftLanguage(lang),
|
|
})
|
|
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 {
|
|
if len(d.VariablesRaw) == 0 {
|
|
d.Variables = PlaceholderMap{}
|
|
return nil
|
|
}
|
|
out := PlaceholderMap{}
|
|
if err := json.Unmarshal(d.VariablesRaw, &out); err != nil {
|
|
return fmt.Errorf("decode submission draft variables: %w", err)
|
|
}
|
|
d.Variables = out
|
|
return nil
|
|
}
|
|
|
|
// normalizeDraftLanguage maps any input to one of the two allowed
|
|
// language values for paliad.submission_drafts.language. Anything other
|
|
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
|
|
// constraint, the project's primary-language default, and the seed
|
|
// behaviour for existing rows that came in before the column existed.
|
|
func normalizeDraftLanguage(lang string) string {
|
|
if strings.EqualFold(strings.TrimSpace(lang), "en") {
|
|
return "en"
|
|
}
|
|
return "de"
|
|
}
|
|
|
|
// Compile-time guard: ensure the *models.User reference in the import
|
|
// graph doesn't get optimised away by linters. The service doesn't
|
|
// dereference User directly — that happens in SubmissionVarsService —
|
|
// but the import keeps the package compile-time-aware of the dependency
|
|
// chain that wires us into the bundle.
|
|
var _ = (*models.User)(nil)
|