feat(docforge): slice 4 — template tables + TemplateStore (t-paliad-349)

Persistence foundation for authoring (slice 6) + generation-on-templates
(slice 7). docforge owns no tables — it defines the contract; paliad
implements it (litigationplanner pattern).

Migration 158_docforge_templates (additive, generic — NOT submission_*-named
so a second docforge consumer reuses it):
  - templates          — catalog row; current_version_id pins the live
                         version (FK added post-create to break the
                         templates<->versions cycle; ON DELETE SET NULL).
  - template_versions  — immutable snapshots; carrier .docx in a bytea
                         column (the TemplateStore bytea backend) + stylemap
                         jsonb. Versioning = snapshot-at-create (PRD A3).
  - template_slots     — variable slots per version; anchor = sentinel token
                         locating the slot in the carrier OOXML (PRD §5
                         lean), slot_key = the bound variable.
  RLS mirrors submission_bases: firm-shared SELECT for authenticated,
  mutations admin-only + gated in Go (no mutation policy = denied).

docforge root: TemplateStore interface + neutral types (TemplateMeta,
Template, TemplateSlot, *Input, TemplateFilter) + ErrTemplateNotFound.
CarrierBytes is format-opaque []byte so the root never imports the docx
adapter; the exporter wraps (CarrierBytes, Stylemap) into a docx.Carrier.

paliad: PgTemplateStore (sqlx, follows the submission_base_service pattern):
List / Get (current version) / GetVersion (pinned snapshot) / Create
(version 1 + pin) / AddVersion (next version + re-pin), all transactional.
Gated live round-trip test (TEST_DATABASE_URL) covers carrier+stylemap+slot
round-trip and the version bump. No handler wires this yet (PRD: no UI in
slice 4).

Verification: go build ./... clean, go vet clean, gofmt clean, full module
test green, migration NoDuplicateSlot structural test green.

m/paliad#157
This commit is contained in:
mAi
2026-05-29 15:35:36 +02:00
parent 8ea78fd376
commit 47deeaf5ed
6 changed files with 789 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
-- t-paliad-349: revert docforge template authoring tables.
--
-- Drop the FK first so the templates ↔ template_versions cycle unwinds,
-- then the tables (template_slots + template_versions cascade from their
-- parents, but drop explicitly for clarity and order-independence).
ALTER TABLE IF EXISTS paliad.templates
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
DROP TABLE IF EXISTS paliad.template_slots;
DROP TABLE IF EXISTS paliad.template_versions;
DROP TABLE IF EXISTS paliad.templates;

View File

@@ -0,0 +1,127 @@
-- t-paliad-349 (m/paliad#157): docforge slice 4 — template authoring tables.
--
-- These three tables are the persistence home for the docforge authoring
-- flow (upload a base .docx → place variable slots → save as a reusable
-- template) and the generation flow (pick a template → bind data →
-- export). They are paliad's implementation of the docforge.TemplateStore
-- contract; docforge itself owns no tables (the litigationplanner pattern).
--
-- Generic on purpose (NOT submission_*-named): authoring is a
-- domain-neutral capability, so the eventual second docforge consumer can
-- reuse the same shape. submission_bases (Gitea-backed, section_spec) stays
-- for the legacy base catalog during the transition; convergence is a
-- later, separate task.
--
-- paliad.templates — one row per template (the catalog entry).
-- paliad.template_versions — immutable snapshots; editing a template
-- inserts a new version. The carrier .docx
-- bytes live here (bytea) — the TemplateStore
-- bytea backend. A draft pins a version
-- (snapshot-at-create, PRD §4 A3) so later
-- edits don't shift an in-flight draft.
-- paliad.template_slots — the variable slots placed in a version's
-- carrier. anchor is the sentinel token the
-- authoring surface injects into the carrier
-- OOXML to locate the slot (PRD §5 lean);
-- slot_key is the variable bound there.
--
-- Visibility: the template catalog is shared firm-wide (every
-- authenticated user generates from it), so SELECT is open to
-- authenticated, mirroring submission_bases. Mutations (upload, edit) are
-- admin-only and gated in Go at the handler layer — no INSERT/UPDATE/DELETE
-- RLS path means RLS denies them by default.
--
-- Slice 4 ships the schema + the TemplateStore only; no rows are seeded and
-- no UI writes here yet (authoring is slice 6, generation-on-templates is
-- slice 7).
CREATE TABLE IF NOT EXISTS paliad.templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE,
name_de text NOT NULL,
name_en text NOT NULL,
kind text NOT NULL DEFAULT 'submission',
source_format text NOT NULL DEFAULT 'docx',
firm text,
is_active bool NOT NULL DEFAULT true,
current_version_id uuid,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT templates_source_format_check CHECK (source_format IN ('docx'))
);
CREATE TABLE IF NOT EXISTS paliad.template_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
template_id uuid NOT NULL REFERENCES paliad.templates(id) ON DELETE CASCADE,
version int NOT NULL,
carrier_blob bytea NOT NULL,
stylemap jsonb NOT NULL DEFAULT '{}'::jsonb,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT template_versions_unique_per_template UNIQUE (template_id, version)
);
CREATE TABLE IF NOT EXISTS paliad.template_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
template_version_id uuid NOT NULL REFERENCES paliad.template_versions(id) ON DELETE CASCADE,
slot_key text NOT NULL,
anchor text NOT NULL,
label text,
order_index int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT template_slots_unique_anchor UNIQUE (template_version_id, anchor)
);
-- current_version_id FK is added after template_versions exists to avoid a
-- circular CREATE-TABLE dependency. ON DELETE SET NULL: dropping the
-- pinned version detaches it rather than cascading the template away.
ALTER TABLE paliad.templates
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
ALTER TABLE paliad.templates
ADD CONSTRAINT templates_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS templates_firm_kind_idx
ON paliad.templates (firm, kind) WHERE is_active;
CREATE INDEX IF NOT EXISTS template_versions_template_idx
ON paliad.template_versions (template_id, version);
CREATE INDEX IF NOT EXISTS template_slots_version_idx
ON paliad.template_slots (template_version_id, order_index);
ALTER TABLE paliad.templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.template_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.template_slots ENABLE ROW LEVEL SECURITY;
-- Firm-shared catalog: any authenticated user reads. Mutations are
-- admin-only, gated in Go (no mutation RLS policy = RLS denies by default).
DROP POLICY IF EXISTS templates_select ON paliad.templates;
CREATE POLICY templates_select
ON paliad.templates FOR SELECT TO authenticated
USING (true);
DROP POLICY IF EXISTS template_versions_select ON paliad.template_versions;
CREATE POLICY template_versions_select
ON paliad.template_versions FOR SELECT TO authenticated
USING (true);
DROP POLICY IF EXISTS template_slots_select ON paliad.template_slots;
CREATE POLICY template_slots_select
ON paliad.template_slots FOR SELECT TO authenticated
USING (true);
DROP TRIGGER IF EXISTS templates_set_updated_at ON paliad.templates;
CREATE TRIGGER templates_set_updated_at
BEFORE UPDATE ON paliad.templates
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.templates IS
't-paliad-349: docforge template catalog. One row per uploaded template; current_version_id pins the live version.';
COMMENT ON TABLE paliad.template_versions IS
't-paliad-349: immutable docforge template snapshots. carrier_blob holds the base .docx bytes (TemplateStore bytea backend).';
COMMENT ON TABLE paliad.template_slots IS
't-paliad-349: variable slots placed in a template version. anchor = sentinel token locating the slot in the carrier OOXML; slot_key = the bound variable.';

View File

@@ -0,0 +1,390 @@
package services
// PgTemplateStore — paliad's Postgres implementation of
// docforge.TemplateStore (t-paliad-349 slice 4). The carrier .docx bytes
// live in a bytea column (paliad.template_versions.carrier_blob); the
// stylemap is jsonb; slots are rows in paliad.template_slots. Versioning is
// snapshot-at-create: Create makes version 1 and pins it as current,
// AddVersion inserts the next version and re-points current.
//
// docforge owns the interface + the neutral types; this is the paliad-side
// data binding. No handler wires this yet — the authoring surface (slice 6)
// and generation-on-templates (slice 7) are the consumers.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// PgTemplateStore implements docforge.TemplateStore against Postgres.
type PgTemplateStore struct {
db *sqlx.DB
}
// compile-time conformance.
var _ docforge.TemplateStore = (*PgTemplateStore)(nil)
// NewPgTemplateStore wires the store.
func NewPgTemplateStore(db *sqlx.DB) *PgTemplateStore {
return &PgTemplateStore{db: db}
}
// 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"`
}
func (r templateMetaRow) toMeta() docforge.TemplateMeta {
return docforge.TemplateMeta{
ID: r.ID.String(),
Slug: derefString(r.Slug),
NameDE: r.NameDE,
NameEN: r.NameEN,
Kind: r.Kind,
SourceFormat: r.SourceFormat,
Firm: derefString(r.Firm),
IsActive: r.IsActive,
Version: r.Version,
}
}
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`
const templateMetaFrom = `FROM paliad.templates t
LEFT JOIN paliad.template_versions v
ON v.id = t.current_version_id`
// List returns catalog metadata for matching templates, without carrier
// bytes.
func (s *PgTemplateStore) List(ctx context.Context, f docforge.TemplateFilter) ([]docforge.TemplateMeta, error) {
q := `SELECT ` + templateMetaColumns + ` ` + templateMetaFrom + ` WHERE 1=1`
var args []any
if f.ActiveOnly {
q += ` AND t.is_active`
}
if f.Firm != "" {
args = append(args, f.Firm)
q += fmt.Sprintf(` AND (t.firm = $%d OR t.firm IS NULL)`, len(args))
}
if f.Kind != "" {
args = append(args, f.Kind)
q += fmt.Sprintf(` AND t.kind = $%d`, len(args))
}
q += ` ORDER BY COALESCE(t.firm, ''), t.name_de`
var rows []templateMetaRow
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, fmt.Errorf("list templates: %w", err)
}
out := make([]docforge.TemplateMeta, len(rows))
for i := range rows {
out[i] = rows[i].toMeta()
}
return out, nil
}
// Get resolves a template to its current version.
func (s *PgTemplateStore) Get(ctx context.Context, id string) (*docforge.Template, error) {
tid, err := uuid.Parse(id)
if err != nil {
return nil, docforge.ErrTemplateNotFound
}
var meta templateMetaRow
err = s.db.GetContext(ctx, &meta,
`SELECT `+templateMetaColumns+` `+templateMetaFrom+` WHERE t.id = $1`, tid)
if errors.Is(err, sql.ErrNoRows) {
return nil, docforge.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("get template: %w", err)
}
tmpl := &docforge.Template{TemplateMeta: meta.toMeta()}
if meta.Version == 0 {
// No version pinned yet — return metadata only (carrier empty).
return tmpl, nil
}
if err := s.loadCurrentVersionContent(ctx, tid, tmpl); err != nil {
return nil, err
}
return tmpl, nil
}
// GetVersion resolves a template to a specific version id — the path a
// draft uses to render its pinned snapshot.
func (s *PgTemplateStore) GetVersion(ctx context.Context, versionID string) (*docforge.Template, error) {
vid, err := uuid.Parse(versionID)
if err != nil {
return nil, docforge.ErrTemplateNotFound
}
var vr struct {
TemplateID uuid.UUID `db:"template_id"`
Version int `db:"version"`
Carrier []byte `db:"carrier_blob"`
Stylemap []byte `db:"stylemap"`
}
err = s.db.GetContext(ctx, &vr,
`SELECT template_id, version, carrier_blob, stylemap
FROM paliad.template_versions WHERE id = $1`, vid)
if errors.Is(err, sql.ErrNoRows) {
return nil, docforge.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("get template version: %w", err)
}
var meta templateMetaRow
err = s.db.GetContext(ctx, &meta,
`SELECT t.id, t.slug, t.name_de, t.name_en, t.kind, t.source_format,
t.firm, t.is_active, $2 AS version
FROM paliad.templates t WHERE t.id = $1`, vr.TemplateID, vr.Version)
if errors.Is(err, sql.ErrNoRows) {
return nil, docforge.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("get template version meta: %w", err)
}
tmpl := &docforge.Template{TemplateMeta: meta.toMeta(), CarrierBytes: vr.Carrier}
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
slots, err := s.loadSlots(ctx, vid)
if err != nil {
return nil, err
}
tmpl.Slots = slots
return tmpl, nil
}
// loadCurrentVersionContent fills carrier + stylemap + slots from the
// template's current_version_id.
func (s *PgTemplateStore) loadCurrentVersionContent(ctx context.Context, templateID uuid.UUID, tmpl *docforge.Template) error {
var vr struct {
ID uuid.UUID `db:"id"`
Carrier []byte `db:"carrier_blob"`
Stylemap []byte `db:"stylemap"`
}
err := s.db.GetContext(ctx, &vr,
`SELECT v.id, v.carrier_blob, v.stylemap
FROM paliad.template_versions v
JOIN paliad.templates t ON t.current_version_id = v.id
WHERE t.id = $1`, templateID)
if errors.Is(err, sql.ErrNoRows) {
return docforge.ErrTemplateNotFound
}
if err != nil {
return fmt.Errorf("load current version: %w", err)
}
tmpl.CarrierBytes = vr.Carrier
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
slots, err := s.loadSlots(ctx, vr.ID)
if err != nil {
return err
}
tmpl.Slots = slots
return nil
}
// loadSlots returns the slots placed in a version, ordered.
func (s *PgTemplateStore) loadSlots(ctx context.Context, versionID uuid.UUID) ([]docforge.TemplateSlot, error) {
var rows []struct {
SlotKey string `db:"slot_key"`
Anchor string `db:"anchor"`
Label *string `db:"label"`
OrderIndex int `db:"order_index"`
}
err := s.db.SelectContext(ctx, &rows,
`SELECT slot_key, anchor, label, order_index
FROM paliad.template_slots
WHERE template_version_id = $1
ORDER BY order_index, slot_key`, versionID)
if err != nil {
return nil, fmt.Errorf("load template slots: %w", err)
}
out := make([]docforge.TemplateSlot, len(rows))
for i, r := range rows {
out[i] = docforge.TemplateSlot{
Key: r.SlotKey,
Anchor: r.Anchor,
Label: derefString(r.Label),
OrderIndex: r.OrderIndex,
}
}
return out, nil
}
// Create inserts a new template + its first version (version 1) and pins
// that version as current.
func (s *PgTemplateStore) Create(ctx context.Context, meta docforge.TemplateMetaInput, first docforge.TemplateVersionInput) (*docforge.Template, error) {
createdBy, err := uuid.Parse(meta.CreatedBy)
if err != nil {
return nil, fmt.Errorf("create template: invalid created_by: %w", err)
}
kind := meta.Kind
if kind == "" {
kind = "submission"
}
format := meta.SourceFormat
if format == "" {
format = "docx"
}
var versionID uuid.UUID
err = s.inTx(ctx, func(tx *sqlx.Tx) error {
var templateID uuid.UUID
if err := tx.GetContext(ctx, &templateID,
`INSERT INTO paliad.templates
(slug, name_de, name_en, kind, source_format, firm, created_by)
VALUES (NULLIF($1, ''), $2, $3, $4, $5, NULLIF($6, ''), $7)
RETURNING id`,
meta.Slug, meta.NameDE, meta.NameEN, kind, format, meta.Firm, createdBy); err != nil {
return fmt.Errorf("insert template: %w", err)
}
var verr error
versionID, verr = insertTemplateVersion(ctx, tx, templateID, 1, first)
if verr != nil {
return verr
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.templates SET current_version_id = $1, updated_at = now() WHERE id = $2`,
versionID, templateID); err != nil {
return fmt.Errorf("pin current version: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return s.GetVersion(ctx, versionID.String())
}
// AddVersion inserts the next version for an existing template and
// re-points current_version to it.
func (s *PgTemplateStore) AddVersion(ctx context.Context, templateID string, v docforge.TemplateVersionInput) (*docforge.Template, error) {
tid, err := uuid.Parse(templateID)
if err != nil {
return nil, docforge.ErrTemplateNotFound
}
var versionID uuid.UUID
err = s.inTx(ctx, func(tx *sqlx.Tx) error {
var exists bool
if err := tx.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM paliad.templates WHERE id = $1)`, tid); err != nil {
return fmt.Errorf("check template exists: %w", err)
}
if !exists {
return docforge.ErrTemplateNotFound
}
var nextVersion int
if err := tx.GetContext(ctx, &nextVersion,
`SELECT COALESCE(MAX(version), 0) + 1 FROM paliad.template_versions WHERE template_id = $1`,
tid); err != nil {
return fmt.Errorf("next version: %w", err)
}
var verr error
versionID, verr = insertTemplateVersion(ctx, tx, tid, nextVersion, v)
if verr != nil {
return verr
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.templates SET current_version_id = $1, updated_at = now() WHERE id = $2`,
versionID, tid); err != nil {
return fmt.Errorf("pin current version: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return s.GetVersion(ctx, versionID.String())
}
// insertTemplateVersion inserts a version row + its slots inside tx and
// returns the new version id.
func insertTemplateVersion(ctx context.Context, tx *sqlx.Tx, templateID uuid.UUID, version int, v docforge.TemplateVersionInput) (uuid.UUID, error) {
createdBy, err := uuid.Parse(v.CreatedBy)
if err != nil {
return uuid.Nil, fmt.Errorf("insert template version: invalid created_by: %w", err)
}
stylemap := v.Stylemap
if stylemap == nil {
stylemap = map[string]string{}
}
smJSON, err := json.Marshal(stylemap)
if err != nil {
return uuid.Nil, fmt.Errorf("marshal stylemap: %w", err)
}
var versionID uuid.UUID
if err := tx.GetContext(ctx, &versionID,
`INSERT INTO paliad.template_versions
(template_id, version, carrier_blob, stylemap, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
templateID, version, v.CarrierBytes, smJSON, createdBy); err != nil {
return uuid.Nil, fmt.Errorf("insert template version: %w", err)
}
for i, slot := range v.Slots {
order := slot.OrderIndex
if order == 0 {
order = i
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.template_slots
(template_version_id, slot_key, anchor, label, order_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5)`,
versionID, slot.Key, slot.Anchor, slot.Label, order); err != nil {
return uuid.Nil, fmt.Errorf("insert template slot %q: %w", slot.Key, err)
}
}
return versionID, nil
}
// inTx runs fn inside a transaction, committing on success and rolling
// back on error or panic.
func (s *PgTemplateStore) inTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
// decodeStylemap unmarshals the stylemap jsonb; an empty/invalid value
// yields an empty map so callers never deref nil.
func decodeStylemap(raw []byte) map[string]string {
out := map[string]string{}
if len(raw) == 0 {
return out
}
_ = json.Unmarshal(raw, &out)
return out
}

View File

@@ -0,0 +1,146 @@
package services
// Live-DB integration tests for PgTemplateStore (t-paliad-349 slice 4).
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
// tests. Exercises the full round-trip: Create (version 1) → Get →
// GetVersion → AddVersion (version 2, current re-pointed) → List, asserting
// the carrier bytes, stylemap, and slots persist and resolve intact.
import (
"bytes"
"context"
"errors"
"os"
"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 TestPgTemplateStore_RoundTrip(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()
store := NewPgTemplateStore(pool)
author := uuid.NewString()
carrierV1 := []byte("PK\x03\x04 fake docx carrier v1")
tmpl, err := store.Create(ctx,
docforge.TemplateMetaInput{
NameDE: "Test-Vorlage",
NameEN: "Test template",
Firm: "HLC",
CreatedBy: author,
},
docforge.TemplateVersionInput{
CarrierBytes: carrierV1,
Stylemap: map[string]string{"paragraph": "Normal", "heading_1": "Heading 1"},
Slots: []docforge.TemplateSlot{
{Key: "project.case_number", Anchor: "{{project.case_number}}", Label: "Aktenzeichen", OrderIndex: 0},
{Key: "parties.claimant.0.name", Anchor: "{{parties.claimant.0.name}}", OrderIndex: 1},
},
CreatedBy: author,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
// Clean up the row (cascades to versions + slots) regardless of outcome.
defer func() {
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE id = $1`, tmpl.ID)
}()
// --- Create assertions: version 1, defaults applied, content intact.
if tmpl.Version != 1 {
t.Errorf("Create version = %d; want 1", tmpl.Version)
}
if tmpl.Kind != "submission" || tmpl.SourceFormat != "docx" {
t.Errorf("defaults: kind=%q format=%q; want submission/docx", tmpl.Kind, tmpl.SourceFormat)
}
if !bytes.Equal(tmpl.CarrierBytes, carrierV1) {
t.Errorf("carrier round-trip mismatch: got %q", tmpl.CarrierBytes)
}
if tmpl.Stylemap["heading_1"] != "Heading 1" {
t.Errorf("stylemap[heading_1] = %q; want 'Heading 1'", tmpl.Stylemap["heading_1"])
}
if len(tmpl.Slots) != 2 {
t.Fatalf("len(slots) = %d; want 2", len(tmpl.Slots))
}
if tmpl.Slots[0].Key != "project.case_number" || tmpl.Slots[0].Label != "Aktenzeichen" {
t.Errorf("slot[0] = %+v; want project.case_number/Aktenzeichen", tmpl.Slots[0])
}
// --- Get by template id resolves the current version.
got, err := store.Get(ctx, tmpl.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Version != 1 || !bytes.Equal(got.CarrierBytes, carrierV1) || len(got.Slots) != 2 {
t.Errorf("Get current version mismatch: v=%d slots=%d", got.Version, len(got.Slots))
}
// --- AddVersion bumps to 2 and re-points current.
carrierV2 := []byte("PK\x03\x04 fake docx carrier v2 edited")
v2, err := store.AddVersion(ctx, tmpl.ID, docforge.TemplateVersionInput{
CarrierBytes: carrierV2,
Stylemap: map[string]string{"paragraph": "HLpat-Body-B0"},
Slots: []docforge.TemplateSlot{{Key: "today", Anchor: "{{today}}", OrderIndex: 0}},
CreatedBy: author,
})
if err != nil {
t.Fatalf("AddVersion: %v", err)
}
if v2.Version != 2 {
t.Errorf("AddVersion version = %d; want 2", v2.Version)
}
if !bytes.Equal(v2.CarrierBytes, carrierV2) || len(v2.Slots) != 1 || v2.Slots[0].Key != "today" {
t.Errorf("AddVersion content mismatch: carrier/slots wrong")
}
// Get now resolves version 2 (current re-pointed).
cur, err := store.Get(ctx, tmpl.ID)
if err != nil {
t.Fatalf("Get after AddVersion: %v", err)
}
if cur.Version != 2 || !bytes.Equal(cur.CarrierBytes, carrierV2) {
t.Errorf("Get after AddVersion = v%d; want v2 with new carrier", cur.Version)
}
// --- List reflects the current version number, filtered by firm.
metas, err := store.List(ctx, docforge.TemplateFilter{Firm: "HLC", ActiveOnly: true})
if err != nil {
t.Fatalf("List: %v", err)
}
var found *docforge.TemplateMeta
for i := range metas {
if metas[i].ID == tmpl.ID {
found = &metas[i]
break
}
}
if found == nil {
t.Fatalf("List did not return the created template")
}
if found.Version != 2 {
t.Errorf("List version = %d; want 2 (current)", found.Version)
}
// --- Unknown id → ErrTemplateNotFound.
if _, err := store.Get(ctx, uuid.NewString()); !errors.Is(err, docforge.ErrTemplateNotFound) {
t.Errorf("Get(unknown) err = %v; want ErrTemplateNotFound", err)
}
}

7
pkg/docforge/errors.go Normal file
View File

@@ -0,0 +1,7 @@
package docforge
import "errors"
// ErrTemplateNotFound is returned by a TemplateStore when a template or
// template version id does not exist. Consumers map it to a 404.
var ErrTemplateNotFound = errors.New("docforge: template not found")

107
pkg/docforge/store.go Normal file
View File

@@ -0,0 +1,107 @@
package docforge
import "context"
// TemplateMeta is the listable metadata for a stored template — cheap to
// list because it carries no carrier bytes.
type TemplateMeta struct {
ID string
Slug string // optional human handle; may be empty
NameDE string
NameEN string
Kind string // consumer-domain tag, e.g. "submission"
SourceFormat string // "docx"
Firm string // may be empty
IsActive bool
Version int // current version number; 0 when no version exists yet
}
// TemplateSlot is one variable slot placed in a template version's carrier.
type TemplateSlot struct {
// Key is the variable bound here, in the placeholder grammar
// (e.g. "project.case_number").
Key string
// Anchor locates the slot within the carrier. With the sentinel
// strategy this is the token the authoring surface injected into the
// carrier OOXML at the slot position.
Anchor string
// Label is an optional human label for the authoring palette.
Label string
// OrderIndex orders slots for display.
OrderIndex int
}
// Template is a stored template resolved to its current version: metadata
// plus everything needed to author or generate — the carrier bytes, the
// stylemap, and the placed slots. CarrierBytes is format-opaque; the .docx
// adapter wraps (CarrierBytes, Stylemap) into a docx.Carrier at compose
// time, so this root type never imports the adapter.
type Template struct {
TemplateMeta
CarrierBytes []byte
Stylemap map[string]string
Slots []TemplateSlot
}
// TemplateMetaInput is the payload for creating a new template (the
// catalog row). ID and Version are assigned by the store.
type TemplateMetaInput struct {
Slug string // optional
NameDE string
NameEN string
Kind string // defaults to "submission" when empty
SourceFormat string // defaults to "docx" when empty
Firm string // optional
CreatedBy string // auth user id (uuid) for the audit column
}
// TemplateVersionInput is the payload for creating a template version: the
// carrier .docx, its stylemap, and the slots placed in it.
type TemplateVersionInput struct {
CarrierBytes []byte
Stylemap map[string]string
Slots []TemplateSlot
CreatedBy string // auth user id (uuid)
}
// TemplateFilter narrows a List. Zero-value fields mean "any".
type TemplateFilter struct {
Firm string // "" = any firm
Kind string // "" = any kind
ActiveOnly bool // true = is_active templates only
}
// TemplateStore persists and loads document templates. docforge defines
// the contract; the consuming application implements it (paliad against
// Postgres, with the carrier bytes in a bytea column). It is the seam the
// authoring surface writes to and the generator reads from — a second
// docforge consumer implements the same interface against its own storage.
//
// Versioning is snapshot-at-create (PRD §4 A3): Create makes version 1 and
// pins it as current; AddVersion inserts the next version and re-points
// current. Drafts pin a specific version so a later edit never shifts an
// in-flight draft.
type TemplateStore interface {
// List returns catalog metadata for templates matching the filter,
// without carrier bytes.
List(ctx context.Context, f TemplateFilter) ([]TemplateMeta, error)
// Get returns a template resolved to its current version (carrier +
// stylemap + slots). Returns ErrTemplateNotFound when id is unknown.
Get(ctx context.Context, id string) (*Template, error)
// GetVersion returns a template resolved to a specific version id —
// the path a draft uses to render its pinned snapshot. Returns
// ErrTemplateNotFound when the version is unknown.
GetVersion(ctx context.Context, versionID string) (*Template, error)
// Create inserts a new template plus its first version (version 1) and
// pins that version as current. Returns the resolved Template.
Create(ctx context.Context, meta TemplateMetaInput, first TemplateVersionInput) (*Template, error)
// AddVersion inserts the next version for an existing template and
// re-points current_version to it. Returns the resolved Template at
// the new version. Returns ErrTemplateNotFound when templateID is
// unknown.
AddVersion(ctx context.Context, templateID string, v TemplateVersionInput) (*Template, error)
}