diff --git a/internal/db/migrations/158_docforge_templates.down.sql b/internal/db/migrations/158_docforge_templates.down.sql new file mode 100644 index 0000000..f5d9f0c --- /dev/null +++ b/internal/db/migrations/158_docforge_templates.down.sql @@ -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; diff --git a/internal/db/migrations/158_docforge_templates.up.sql b/internal/db/migrations/158_docforge_templates.up.sql new file mode 100644 index 0000000..ff8525b --- /dev/null +++ b/internal/db/migrations/158_docforge_templates.up.sql @@ -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.'; diff --git a/internal/services/template_store.go b/internal/services/template_store.go new file mode 100644 index 0000000..399ef9a --- /dev/null +++ b/internal/services/template_store.go @@ -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 +} diff --git a/internal/services/template_store_live_test.go b/internal/services/template_store_live_test.go new file mode 100644 index 0000000..7fab613 --- /dev/null +++ b/internal/services/template_store_live_test.go @@ -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) + } +} diff --git a/pkg/docforge/errors.go b/pkg/docforge/errors.go new file mode 100644 index 0000000..80610cb --- /dev/null +++ b/pkg/docforge/errors.go @@ -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") diff --git a/pkg/docforge/store.go b/pkg/docforge/store.go new file mode 100644 index 0000000..68f3e92 --- /dev/null +++ b/pkg/docforge/store.go @@ -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) +}