Files
paliad/internal/services/submission_draft_service.go
mAi 4fc3005db8 mAi: #109 - t-paliad-277 submission generator party selector + import-from-project
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.

Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.

Schema: mig 131 adds two columns to paliad.submission_drafts:
  - selected_parties uuid[] DEFAULT '{}'::uuid[]
    Empty = include every party (legacy default).
    Non-empty = restrict to the subset, grouped by role at substitution.
  - last_imported_at timestamptz NULL
    Bumped each "Aus Projekt importieren" click; surfaced in UI.

Backend:
  - SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
    restricts the resolved bag before role bucketing.
  - addPartyVars emits THREE coexisting forms per role: comma-joined
    (parties.claimants), indexed (parties.claimant.0.name), and flat
    legacy (parties.claimant.name → first selected claimant). Flat
    aliases are kept forever per the issue's backward-compat contract.
  - SubmissionDraftService.ImportFromProject strips overrides for
    project-derived prefixes and bumps last_imported_at; rejects
    project-less drafts (nothing to import from).
  - New endpoint POST /api/submission-drafts/{id}/import-from-project.
  - DraftPatch + PATCH handlers accept selected_parties.
  - submissionDraftView now ships available_parties so the editor can
    render the picker without an extra round-trip.

Frontend:
  - submission-draft.tsx: new import-row + parties block in the sidebar.
  - client/submission-draft.ts: paintImportRow / paintPartyPicker /
    onPartySelectionChange / onImportFromProject; group parties by
    role bucket (claimant / defendant / other) with DE+EN role-string
    matching to mirror the backend bucketing.
  - 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
  - CSS for the picker + import row in global.css.

Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.

Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
2026-05-25 16:51:35 +02:00

707 lines
25 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"
"github.com/lib/pq"
"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"`
VariablesRaw []byte `db:"variables" json:"-"`
SelectedPartiesRaw pq.StringArray `db:"selected_parties" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,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"`
// SelectedParties is the parsed uuid form of SelectedPartiesRaw —
// populated on read by decodeSelectedParties(). An empty slice keeps
// the backward-compat "include every party" behaviour; a non-empty
// slice restricts the variable bag to the listed paliad.parties rows.
SelectedParties []uuid.UUID `json:"selected_parties"`
}
// 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
// SelectedParties: nil = no change. A non-nil pointer always writes
// the column; pass *p = nil or an empty slice to reset to "include
// every party on the project" (the backward-compat default).
SelectedParties *[]uuid.UUID
}
// 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,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
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].decode(); 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.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
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].decode(); 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.decode(); 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.decode(); 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
}
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name)
VALUES ($1, $2, $3, $4)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
if err := d.decode(); 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.SelectedParties != nil {
ids := *patch.SelectedParties
strs := make([]string, 0, len(ids))
for _, id := range ids {
strs = append(strs, id.String())
}
setParts = append(setParts, fmt.Sprintf("selected_parties = $%d::uuid[]", idx))
args = append(args, pq.StringArray(strs))
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.decode(); 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
}
// ImportFromProject re-pulls every project-derived variable on the
// draft by stripping the lawyer's overrides for those keys and bumping
// `last_imported_at`. Project-derived prefixes today are project.*,
// parties.*, deadline.* and (because the rule is keyed on
// submission_code) procedural_event.* / rule.*; the lawyer's overrides
// for firm.*, today.*, user.* survive because those values aren't
// "imported from the project" in any meaningful sense.
//
// Idempotent on repeat clicks: nothing else mutates on the second
// call apart from the new timestamp. The draft must be owned by the
// caller (Get() applies the same ErrNotFound semantics as the rest of
// the service).
func (s *SubmissionDraftService) ImportFromProject(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
existing, err := s.Get(ctx, userID, draftID)
if err != nil {
return nil, err
}
if existing.ProjectID == nil {
// No project to import from — surface as 400 via ErrInvalidInput.
return nil, fmt.Errorf("%w: cannot import from project on a project-less draft", ErrInvalidInput)
}
// Strip overrides that came from project state.
cleaned := PlaceholderMap{}
for k, v := range existing.Variables {
if isProjectDerivedKey(k) {
continue
}
cleaned[k] = v
}
raw, err := json.Marshal(cleaned)
if err != nil {
return nil, fmt.Errorf("marshal variables: %w", err)
}
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`UPDATE paliad.submission_drafts
SET variables = $1::jsonb,
last_imported_at = now()
WHERE id = $2 AND user_id = $3
RETURNING `+draftColumns,
string(raw), draftID, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionDraftNotFound
}
if err != nil {
return nil, fmt.Errorf("import from project: %w", err)
}
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
}
// isProjectDerivedKey reports whether a placeholder key sources its
// value from the project record (rather than firm-wide or user-wide
// state). The "Aus Projekt importieren" affordance strips overrides
// for exactly these keys so the lawyer's manual edits don't survive
// a re-pull.
func isProjectDerivedKey(key string) bool {
switch {
case strings.HasPrefix(key, "project."):
return true
case strings.HasPrefix(key, "parties."):
return true
case strings.HasPrefix(key, "deadline."):
return true
case strings.HasPrefix(key, "procedural_event."):
return true
case strings.HasPrefix(key, "rule."):
return true
}
return false
}
// 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,
SelectedParties: draft.SelectedParties,
})
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 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
}
// decode fills the parsed views (Variables, SelectedParties) from the
// raw scan fields. Called by every fetch path so the caller sees both
// populated together.
func (d *SubmissionDraft) decode() error {
if err := d.decodeVariables(); err != nil {
return err
}
return d.decodeSelectedParties()
}
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
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
}
// decodeSelectedParties parses the uuid[] payload from pq.StringArray
// into []uuid.UUID. Unparseable entries are dropped so a single bad
// row never bricks the fetch — the worst case is one extra party
// silently dropped from the selection, which surfaces as it not being
// rendered in the merged document.
func (d *SubmissionDraft) decodeSelectedParties() error {
if len(d.SelectedPartiesRaw) == 0 {
d.SelectedParties = nil
return nil
}
out := make([]uuid.UUID, 0, len(d.SelectedPartiesRaw))
for _, s := range d.SelectedPartiesRaw {
id, err := uuid.Parse(s)
if err != nil {
continue
}
out = append(out, id)
}
d.SelectedParties = out
return nil
}
// 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)