Compare commits

...

2 Commits

Author SHA1 Message Date
mAi
e1e8db7fc9 Merge: t-paliad-349 docforge slice 7 — generation on uploaded templates (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 17:57:30 +02:00
mAi
b746ec36c7 feat(docforge): slice 7 — generation on uploaded templates (t-paliad-349)
A submission draft can now render from an uploaded docforge template
instead of a legacy Gitea base. DB-VERIFIED against TEST_DATABASE_URL (the
head greenlit option C) before commit — not just compiled.

Schema: migration 159 adds submission_drafts.template_version_id (nullable,
FK template_versions ON DELETE SET NULL) — the snapshot pin (PRD A3). A
later template edit creates a new version; the pinned draft keeps rendering
its version.

Draft service: TemplateVersionID on the model + draftColumns + the JOIN
list + DraftPatch (two-level pointer like base_id) + Update SET. Column-sync
verified live (Create_seeds_section_rows + the new pin test both pass).

Export/preview (handlers): a template-version path checked FIRST — load the
carrier via TemplateStore.GetVersion, render via the existing Export/
RenderPreview (the carrier already carries {{slots}}; no Composer/sections
needed). Falls through to base_id / v1 if the pin is missing. Both preview
sites + the view assembly branch on it.

Store: TemplateMeta.VersionID exposes the current version's row id (slice-4
gap — a consumer needs it to pin); populated in List/Get/GetVersion + the
authoring JSON. New GET /api/templates (authenticated, firm-filtered) is the
picker list any lawyer reads; admin authoring endpoints stay gated.

Frontend: the submission editor's base picker now offers uploaded templates
as a 'tpl:<version_id>' optgroup; selecting one PATCHes template_version_id
(clearing base_id) and vice versa — mutually exclusive render paths.

Live test (submission_draft_template_live_test.go, gated): pin round-trips
Update→Get, the uploaded carrier renders ({{firm.name}}→HLC via Export), and
clearing nulls it — all PASS against real Postgres.

Verification: go build/vet/gofmt clean; bun build + bun test 274/274; slice-7
+ slice-4 store + draft/composer live tests PASS against TEST_DATABASE_URL.
Pre-existing env failures (approval/projection seed $1-type quirk,
migration136 stale deadline_rules table) are unrelated — confirmed my branch
touches none of that code.

m/paliad#157
2026-05-29 17:55:31 +02:00
10 changed files with 530 additions and 75 deletions

View File

@@ -35,6 +35,9 @@ interface SubmissionDraftJSON {
// path stays the fallback). composer_meta carries the seed-time
// section order in later slices.
base_id?: string | null;
// t-paliad-349 slice 7 — pinned uploaded docforge template version.
// Mutually exclusive with base_id in practice (export checks this first).
template_version_id?: string | null;
composer_meta?: Record<string, unknown>;
created_at: string;
updated_at: string;
@@ -71,6 +74,17 @@ interface SubmissionBaseRow {
section_count: number;
}
// t-paliad-349 slice 7 — an uploaded docforge template offered in the
// picker for generation. version_id is what a draft pins.
interface PickerTemplate {
id: string;
name_de: string;
name_en: string;
firm?: string | null;
version: number;
version_id?: string;
}
interface AvailablePartyJSON {
id: string;
name: string;
@@ -307,6 +321,9 @@ interface State {
// completes) keeps the picker hidden permanently for this load.
bases: SubmissionBaseRow[];
basesLoaded: boolean;
// t-paliad-349 slice 7 — uploaded templates offered in the picker.
templates: PickerTemplate[];
templatesLoaded: boolean;
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
// Go catalogue (GET /api/docforge/variables), the single source of
// truth. Empty until the fetch lands; labelFor falls back to the raw
@@ -341,6 +358,8 @@ const state: State = {
addPartyBusy: false,
bases: [],
basesLoaded: false,
templates: [],
templatesLoaded: false,
varLabels: {},
};
@@ -366,6 +385,11 @@ async function boot(): Promise<void> {
console.warn("submission-draft: base catalog fetch failed", err);
state.basesLoaded = true;
});
// t-paliad-349 slice 7 — uploaded-template catalog for the picker.
loadTemplates().catch(err => {
console.warn("submission-draft: template catalog fetch failed", err);
state.templatesLoaded = true;
});
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
// before the first paint so the sidebar form labels render. Awaited
@@ -1168,29 +1192,46 @@ async function loadBases(): Promise<void> {
if (state.view) paintBasePicker();
}
// loadTemplates fetches the firm-shared uploaded-template catalog
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
// simply offers no uploaded templates, the editor stays usable.
async function loadTemplates(): Promise<void> {
const res = await fetch("/api/templates", { credentials: "include" });
if (!res.ok) {
throw new Error("template list HTTP " + res.status);
}
const body = await res.json() as { templates?: PickerTemplate[] };
state.templates = (body.templates ?? []).filter(t => !!t.version_id);
state.templatesLoaded = true;
if (state.view) paintBasePicker();
}
function paintBasePicker(): void {
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
if (!row || !sel || !state.view) return;
// Hide the picker until the catalog has loaded AND the catalog has
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
// keeps the picker hidden indefinitely so the editor stays usable.
if (!state.basesLoaded || state.bases.length === 0) {
// Hide the picker only when BOTH catalogs are loaded-but-empty. As long
// as bases OR uploaded templates exist, the picker is useful. A failed
// fetch leaves the respective list empty; the editor stays usable.
const hasBases = state.basesLoaded && state.bases.length > 0;
const hasTemplates = state.templatesLoaded && state.templates.length > 0;
if (!hasBases && !hasTemplates) {
row.style.display = "none";
return;
}
row.style.display = "";
// Rebuild the <option> list each paint so language toggles + base
// catalog updates flow through.
// Rebuild the <option> list each paint so language toggles + catalog
// updates flow through.
sel.innerHTML = "";
const currentBaseID = state.view.draft.base_id ?? "";
const currentTplVersion = state.view.draft.template_version_id ?? "";
// "Keine Vorlagenbasis" only listed when the draft is currently in
// that state (pre-Composer / cleared). Avoids tempting the lawyer
// to clear after they've already picked one.
if (!currentBaseID) {
// that state (no base, no template). Avoids tempting the lawyer to
// clear after they've already picked one.
if (!currentBaseID && !currentTplVersion) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
@@ -1203,6 +1244,21 @@ function paintBasePicker(): void {
if (b.id === currentBaseID) opt.selected = true;
sel.appendChild(opt);
}
// t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
// The value is "tpl:<version_id>" so onBaseChange can route it to the
// template_version_id PATCH instead of base_id.
if (hasTemplates) {
const group = document.createElement("optgroup");
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
for (const tmpl of state.templates) {
const opt = document.createElement("option");
opt.value = "tpl:" + tmpl.version_id;
opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
if (tmpl.version_id === currentTplVersion) opt.selected = true;
group.appendChild(opt);
}
sel.appendChild(group);
}
// Wire change handler once per paint. Removing then re-adding
// keeps the binding consistent across repaints (e.g. after
@@ -1210,12 +1266,17 @@ function paintBasePicker(): void {
sel.onchange = () => { onBaseChange(sel.value); };
}
async function onBaseChange(newBaseID: string): Promise<void> {
async function onBaseChange(newValue: string): Promise<void> {
if (!state.view) return;
const payload: Record<string, unknown> = {
// Empty string in the picker maps to null = clear.
base_id: newBaseID === "" ? null : newBaseID,
};
// The picker mixes legacy bases (plain uuid) and uploaded templates
// ("tpl:<version_id>"). Route to the matching field and clear the other
// so the two render paths stay mutually exclusive. Empty = clear both.
let payload: Record<string, unknown>;
if (newValue.startsWith("tpl:")) {
payload = { template_version_id: newValue.slice(4), base_id: null };
} else {
payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
}
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}`,

View File

@@ -0,0 +1,6 @@
-- t-paliad-349: revert the template-version pin on submission drafts.
DROP INDEX IF EXISTS paliad.submission_drafts_template_version_idx;
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS template_version_id;

View File

@@ -0,0 +1,28 @@
-- t-paliad-349 (m/paliad#157): docforge slice 7 — pin an uploaded template
-- version onto a submission draft (generation-on-uploaded-templates).
--
-- A draft can now source its document from a docforge uploaded template
-- (paliad.template_versions) instead of a legacy Gitea base. template_version_id
-- is the snapshot pin (PRD §4 A3): the draft renders the exact carrier of the
-- version it was bound to, so a later template edit (which creates a new
-- version) doesn't shift an in-flight draft.
--
-- Nullable + additive: existing drafts keep template_version_id NULL and
-- render via their existing path (Composer base_id, or the v1 fallback).
-- The three sources are mutually exclusive in practice; the export path
-- checks template_version_id first, then base_id, then v1.
--
-- ON DELETE SET NULL: if the pinned version is removed, the draft detaches
-- and falls back rather than failing — same posture as base_id's
-- ON DELETE SET NULL.
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS template_version_id uuid
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS submission_drafts_template_version_idx
ON paliad.submission_drafts (template_version_id)
WHERE template_version_id IS NOT NULL;
COMMENT ON COLUMN paliad.submission_drafts.template_version_id IS
't-paliad-349: pinned docforge template version (snapshot-at-create). NULL = render via base_id Composer path or v1 fallback.';

View File

@@ -463,6 +463,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
// t-paliad-349 slice 7 — firm-shared template picker list for
// generation (any authenticated lawyer; admin authoring stays gated).
protected.HandleFunc("GET /api/templates", handlePickerTemplates)
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
// for inline editor autosave. URL keyed on draft_id + section_id;
// owner-scoped via SubmissionDraftService.Get.

View File

@@ -44,6 +44,7 @@ import (
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// submissionDraftPreviewTimeout caps a single preview round-trip.
@@ -115,10 +116,14 @@ type submissionDraftJSON struct {
// pre-Composer drafts; the editor sidebar surfaces this in the
// base picker. PATCH accepts {"base_id": "<uuid>"} or
// {"base_id": null} to set or clear.
BaseID *uuid.UUID `json:"base_id"`
ComposerMeta map[string]any `json:"composer_meta"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
BaseID *uuid.UUID `json:"base_id"`
// TemplateVersionID — pinned uploaded docforge template version
// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
TemplateVersionID *uuid.UUID `json:"template_version_id"`
ComposerMeta map[string]any `json:"composer_meta"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// submissionSectionJSON is the on-the-wire row for each per-draft
@@ -126,15 +131,15 @@ type submissionDraftJSON struct {
// section stack but doesn't yet edit prose. Slice B makes content_md_*
// editable + adds the PATCH endpoint.
type submissionSectionJSON struct {
ID uuid.UUID `json:"id"`
SectionKey string `json:"section_key"`
OrderIndex int `json:"order_index"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
ID uuid.UUID `json:"id"`
SectionKey string `json:"section_key"`
OrderIndex int `json:"order_index"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
}
type submissionRuleSummary struct {
@@ -170,6 +175,11 @@ type submissionDraftPatchInput struct {
// admin-recovery flows).
BaseID *uuid.UUID `json:"base_id,omitempty"`
BaseIDSet bool `json:"-"`
// TemplateVersionID pins an uploaded docforge template version
// (t-paliad-349 slice 7). Same three-state presence contract as
// base_id: absent = no change, uuid = pin, null = clear.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
TemplateVersionIDSet bool `json:"-"`
}
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
@@ -193,6 +203,9 @@ func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
if _, ok := raw["base_id"]; ok {
p.BaseIDSet = true
}
if _, ok := raw["template_version_id"]; ok {
p.TemplateVersionIDSet = true
}
return nil
}
@@ -437,6 +450,12 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if input.BaseIDSet {
patch.BaseID = &input.BaseID
}
if input.TemplateVersionIDSet {
if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
return
}
patch.TemplateVersionID = &input.TemplateVersionID
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -517,7 +536,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
tplBytes, err := previewTemplateBytes(ctx, d)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -597,6 +616,48 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
}
}
// validateTemplateVersionPin checks that a non-nil template-version pin
// refers to an existing version (404 otherwise), so a PATCH can't bind a
// draft to a vanished template. A nil pin (clear) is always valid. Returns
// true when the patch may proceed; writes the error response otherwise.
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
if pin == nil {
return true
}
if dbSvc.templateStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
return false
}
if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
if errors.Is(err, docforge.ErrTemplateNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
} else {
writeServiceError(w, err)
}
return false
}
return true
}
// previewTemplateBytes returns the carrier bytes to render a draft's
// preview: the pinned uploaded-template version's carrier when set
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
// template (v1/legacy path). A missing pinned version falls through to the
// upstream resolution rather than failing.
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
if err == nil {
return tmpl.CarrierBytes, nil
}
if !errors.Is(err, docforge.ErrTemplateNotFound) {
return nil, err
}
}
b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
return b, err
}
// exportSubmissionDraft is the shared render entry point used by both
// the project-scoped and global export handlers (t-paliad-313 Slice B).
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
@@ -607,6 +668,27 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
//
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
// t-paliad-349 slice 7 — uploaded-template path, checked first. The
// pinned version's carrier already carries {{slots}}; Export resolves
// the bag + substitutes them via the same renderer the v1 path uses
// (no Composer/sections — the uploaded doc IS the document). A missing
// pinned version falls through to the base_id / v1 paths.
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
switch {
case err == nil:
docx, resolved, rerr := dbSvc.submissionDraft.Export(ctx, d, tmpl.CarrierBytes)
if rerr != nil {
return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
}
return docx, resolved, "", false, nil
case errors.Is(err, docforge.ErrTemplateNotFound):
log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
default:
return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
}
}
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
switch {
@@ -853,16 +935,21 @@ type globalDraftPatchInput struct {
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
BaseID *uuid.UUID `json:"base_id,omitempty"`
baseIDProvided bool
// TemplateVersionID + provided flag — uploaded-template pin
// (t-paliad-349 slice 7), same present/absent contract as base_id.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
templateVersionIDProvided bool
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -874,14 +961,16 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
g.BaseID = a.BaseID
// Detect whether "project_id" / "base_id" were present in the JSON
// object.
g.TemplateVersionID = a.TemplateVersionID
// Detect whether "project_id" / "base_id" / "template_version_id" were
// present in the JSON object.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, g.projectIDProvided = raw["project_id"]
_, g.baseIDProvided = raw["base_id"]
_, g.templateVersionIDProvided = raw["template_version_id"]
return nil
}
@@ -926,6 +1015,13 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
bid := in.BaseID // may be nil → clear
patch.BaseID = &bid
}
if in.templateVersionIDProvided {
if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
return
}
tv := in.TemplateVersionID // may be nil → clear
patch.TemplateVersionID = &tv
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
@@ -1155,6 +1251,23 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
// carrier. The Gitea tier / language-fallback notions don't apply (they
// describe the upstream fallback chain), so they stay at their zero
// values. A missing pinned version falls through to upstream resolution.
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
if rerr != nil {
return nil, rerr
}
view.PreviewHTML = html
return view, nil
} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
return nil, terr
}
}
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
@@ -1184,11 +1297,11 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
@@ -1306,21 +1419,22 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
meta = map[string]any{}
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
BaseID: d.BaseID,
ComposerMeta: meta,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
BaseID: d.BaseID,
TemplateVersionID: d.TemplateVersionID,
ComposerMeta: meta,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}

View File

@@ -33,6 +33,7 @@ import (
"io"
"net/http"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
)
@@ -51,6 +52,7 @@ type templateMetaJSON struct {
Firm string `json:"firm,omitempty"`
IsActive bool `json:"is_active"`
Version int `json:"version"`
VersionID string `json:"version_id,omitempty"`
}
type templateSlotJSON struct {
@@ -70,7 +72,7 @@ func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
return templateMetaJSON{
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
IsActive: m.IsActive, Version: m.Version,
IsActive: m.IsActive, Version: m.Version, VersionID: m.VersionID,
}
}
@@ -129,6 +131,31 @@ func handleListTemplates(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
}
// handlePickerTemplates backs GET /api/templates — the firm-shared catalog
// any authenticated lawyer reads to pick an uploaded template for
// generation (t-paliad-349 slice 7). Unlike the admin list it filters by
// firm (the deployment's branding firm + firm-agnostic templates), matching
// the submission_bases picker contract. Metadata only — no carrier bytes.
func handlePickerTemplates(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
metas, err := dbSvc.templateStore.List(r.Context(),
docforge.TemplateFilter{Firm: branding.Name, ActiveOnly: true})
if err != nil {
writeTemplateError(w, err)
return
}
out := make([]templateMetaJSON, 0, len(metas))
for _, m := range metas {
out = append(out, metaJSON(m))
}
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
}
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
// the uploaded .docx, validates it parses, detects any slots already in it,
// and creates the template at version 1.

View File

@@ -63,12 +63,17 @@ type SubmissionDraft struct {
// ON DELETE SET NULL keeps a draft renderable if its base is
// removed; the lawyer picks a new one via the sidebar.
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
// TemplateVersionID pins an uploaded docforge template version
// (t-paliad-349 slice 7). NULL = render via base_id Composer path or
// the v1 fallback; non-NULL = render the pinned version's carrier.
// The export/preview path checks this first. ON DELETE SET NULL.
TemplateVersionID *uuid.UUID `db:"template_version_id" json:"template_version_id,omitempty"`
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
// Slice A: empty default. Future slices populate section_order,
// hidden_sections, etc.
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
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.
@@ -170,6 +175,14 @@ type DraftPatch struct {
// content is unaffected — the base swap is render-side only.
// t-paliad-313.
BaseID **uuid.UUID
// TemplateVersionID pins (or clears) an uploaded docforge template
// version. Same three-state two-level pointer as BaseID:
// nil → no change
// *p == nil → clear (back to base_id / v1)
// **p → pin the version (validated via TemplateStore.GetVersion)
// t-paliad-349 slice 7.
TemplateVersionID **uuid.UUID
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -186,7 +199,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
base_id, composer_meta,
base_id, template_version_id, composer_meta,
created_at, updated_at`
// List returns every draft for (project, submission_code, user)
@@ -239,7 +252,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
d.base_id, d.composer_meta,
d.base_id, d.template_version_id, d.composer_meta,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
@@ -567,6 +580,15 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.TemplateVersionID != nil {
newTV := *patch.TemplateVersionID // *uuid.UUID — nil means clear
// Existence is enforced by the FK + validated at the handler via
// TemplateStore.GetVersion (clean 404); here we just set it.
setParts = append(setParts, fmt.Sprintf("template_version_id = $%d", idx))
args = append(args, newTV)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -878,7 +900,6 @@ func normalizeDraftLanguage(lang string) string {
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 —

View File

@@ -0,0 +1,184 @@
package services
// Live-DB test for generation-on-uploaded-templates (t-paliad-349 slice 7).
// Skipped without TEST_DATABASE_URL. Verifies the shipped draft-service
// change end-to-end against real Postgres:
// 1. submission_drafts.template_version_id round-trips through
// Update → Get (the column-sync + patch path), and clears to NULL.
// 2. An uploaded template's carrier renders via the v1 Export path:
// {{firm.name}} in the carrier substitutes to the branding name.
//
// This is the verification the head greenlit (option C) before the
// shipped-code change is committed.
import (
"archive/zip"
"bytes"
"context"
"io"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
func TestSubmissionDraft_TemplateVersionPin(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
email := "tplpin-" + userID.String()[:8] + "@hlc.com"
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE created_by = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Tpl Pin', 'munich', 'standard', 'de')`, userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
store := NewPgTemplateStore(pool)
// Uploaded template whose carrier carries a {{firm.name}} slot.
carrier := minimalDocxWithBody(t, `<w:p><w:r><w:t>Von {{firm.name}}</w:t></w:r></w:p>`)
tmpl, err := store.Create(ctx,
docforge.TemplateMetaInput{NameDE: "Pin-Test", NameEN: "Pin test", CreatedBy: userID.String()},
docforge.TemplateVersionInput{CarrierBytes: carrier, CreatedBy: userID.String()})
if err != nil {
t.Fatalf("store.Create: %v", err)
}
if tmpl.VersionID == "" {
t.Fatalf("template VersionID empty — generation can't pin it")
}
versionID := uuid.MustParse(tmpl.VersionID)
// Project-less draft on a code that has a published rule (so Build
// resolves). No composer attached → plain draft.
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("drafts.Create: %v", err)
}
if d.TemplateVersionID != nil {
t.Errorf("fresh draft has a template pin: %v", d.TemplateVersionID)
}
// --- Pin the version via Update, read it back via Get.
pin := &versionID
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &pin}); err != nil {
t.Fatalf("Update(pin): %v", err)
}
got, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("Get after pin: %v", err)
}
if got.TemplateVersionID == nil || *got.TemplateVersionID != versionID {
t.Fatalf("pinned template_version_id = %v; want %s", got.TemplateVersionID, versionID)
}
// --- The uploaded carrier renders via Export: {{firm.name}} → "HLC".
out, _, err := drafts.Export(ctx, got, carrier)
if err != nil {
t.Fatalf("Export: %v", err)
}
doc := unzipDocumentXML(t, out)
if strings.Contains(doc, "{{firm.name}}") {
t.Errorf("placeholder not substituted; doc=%s", doc)
}
if !strings.Contains(doc, "HLC") {
t.Errorf("firm.name did not resolve to HLC; doc=%s", doc)
}
// --- Clearing the pin sets it back to NULL.
var nilPin *uuid.UUID
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &nilPin}); err != nil {
t.Fatalf("Update(clear): %v", err)
}
cleared, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("Get after clear: %v", err)
}
if cleared.TemplateVersionID != nil {
t.Errorf("template_version_id = %v after clear; want nil", cleared.TemplateVersionID)
}
}
// minimalDocxWithBody builds a tiny valid .docx (zip) whose document.xml
// body is the given inner XML.
func minimalDocxWithBody(t *testing.T, inner string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
add("[Content_Types].xml",
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
add("word/document.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
`<w:body>`+inner+`</w:body></w:document>`)
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func unzipDocumentXML(t *testing.T, b []byte) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open document.xml: %v", err)
}
defer rc.Close()
data, _ := io.ReadAll(rc)
return string(data)
}
t.Fatal("document.xml not found in output")
return ""
}

View File

@@ -40,19 +40,20 @@ func NewPgTemplateStore(db *sqlx.DB) *PgTemplateStore {
// templateMetaRow scans the catalog metadata + the current version number
// (via LEFT JOIN, 0 when no version pinned yet).
type templateMetaRow struct {
ID uuid.UUID `db:"id"`
Slug *string `db:"slug"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
Kind string `db:"kind"`
SourceFormat string `db:"source_format"`
Firm *string `db:"firm"`
IsActive bool `db:"is_active"`
Version int `db:"version"`
ID uuid.UUID `db:"id"`
Slug *string `db:"slug"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
Kind string `db:"kind"`
SourceFormat string `db:"source_format"`
Firm *string `db:"firm"`
IsActive bool `db:"is_active"`
Version int `db:"version"`
VersionID *uuid.UUID `db:"version_id"`
}
func (r templateMetaRow) toMeta() docforge.TemplateMeta {
return docforge.TemplateMeta{
m := docforge.TemplateMeta{
ID: r.ID.String(),
Slug: derefString(r.Slug),
NameDE: r.NameDE,
@@ -63,11 +64,16 @@ func (r templateMetaRow) toMeta() docforge.TemplateMeta {
IsActive: r.IsActive,
Version: r.Version,
}
if r.VersionID != nil {
m.VersionID = r.VersionID.String()
}
return m
}
const templateMetaColumns = `t.id, t.slug, t.name_de, t.name_en, t.kind,
t.source_format, t.firm, t.is_active,
COALESCE(v.version, 0) AS version`
COALESCE(v.version, 0) AS version,
v.id AS version_id`
const templateMetaFrom = `FROM paliad.templates t
LEFT JOIN paliad.template_versions v
@@ -162,6 +168,7 @@ func (s *PgTemplateStore) GetVersion(ctx context.Context, versionID string) (*do
return nil, fmt.Errorf("get template version meta: %w", err)
}
tmpl := &docforge.Template{TemplateMeta: meta.toMeta(), CarrierBytes: vr.Carrier}
tmpl.VersionID = vid.String() // the resolved version is the one requested
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
slots, err := s.loadSlots(ctx, vid)
if err != nil {

View File

@@ -13,7 +13,11 @@ type TemplateMeta struct {
SourceFormat string // "docx"
Firm string // may be empty
IsActive bool
Version int // current version number; 0 when no version exists yet
Version int // current version number; 0 when no version exists yet
VersionID string // current version row id; "" when no version exists yet.
// A draft pins VersionID to snapshot this exact version (PRD §4 A3):
// a later template edit creates a new version and re-points current,
// but the pinned draft keeps rendering VersionID.
}
// TemplateSlot is one variable slot placed in a template version's carrier.