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:
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
12
internal/db/migrations/158_docforge_templates.down.sql
Normal 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;
|
||||
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
127
internal/db/migrations/158_docforge_templates.up.sql
Normal 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.';
|
||||
390
internal/services/template_store.go
Normal file
390
internal/services/template_store.go
Normal 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
|
||||
}
|
||||
146
internal/services/template_store_live_test.go
Normal file
146
internal/services/template_store_live_test.go
Normal 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
7
pkg/docforge/errors.go
Normal 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
107
pkg/docforge/store.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user