The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.
Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
(slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
users, mutations admin-only (handler-enforced, no RLS write paths).
Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
_skeleton.docx. Each section_spec carries the 10-section default
(letterhead, caption, introduction, requests, facts, legal_argument,
evidence, exhibits, closing, signature) with bilingual labels +
bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
composer_meta jsonb (default '{}'). Purely additive; pre-Composer
drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
section_key, order_index, kind ∈ {prose,requests,evidence},
label_de/en, included, content_md_de/en). RLS mirrors
submission_drafts (owner-scoped + can_see_project, four policies).
Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
the firm default base and seeds base_id + section rows in one tx.
Composer wiring is additive — when bases==nil the service stays
v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
on both project-scoped and global drafts accept "base_id".
Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
(hidden until catalog loads); section-list pane above the preview
(hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
paintBasePicker rebuilds <option> list on every paint; onBaseChange
PATCHes base_id and repaints; paintSectionList renders each section
read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
Pre-Composer drafts get the picker (catalog still loads) but the
section pane stays hidden until they pick a base on a new draft.
Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
(pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
seeded 10 default sections on both bases; GetDefaultForCode picks
hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
base_id + 10 section rows in tx with ascending order_index and
bilingual labels populated.
NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.
Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).
t-paliad-313
275 lines
8.8 KiB
Go
275 lines
8.8 KiB
Go
package services
|
|
|
|
// Submission base catalog service — Composer Slice A (t-paliad-313,
|
|
// design doc docs/design-submission-generator-v2-2026-05-26.md §4.2 +
|
|
// §5.1).
|
|
//
|
|
// Each row in paliad.submission_bases maps a stable slug onto a Gitea
|
|
// path (the .docx body) plus a JSON section spec that drives the
|
|
// editor's default section seeding. Slice A surfaces this catalog via
|
|
// a sidebar picker and uses GetDefaultForCode to pre-fill base_id on
|
|
// new drafts.
|
|
//
|
|
// Read-only — admin mutations land in Slice C's /admin/submission-bases
|
|
// editor. Visibility is wide-open SELECT (the catalog is shared
|
|
// firm-wide); RLS denies mutations by default.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// SubmissionBase mirrors a row in paliad.submission_bases.
|
|
type SubmissionBase struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
Slug string `db:"slug" json:"slug"`
|
|
Firm *string `db:"firm" json:"firm,omitempty"`
|
|
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
|
|
LabelDE string `db:"label_de" json:"label_de"`
|
|
LabelEN string `db:"label_en" json:"label_en"`
|
|
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
|
|
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
|
|
GiteaPath string `db:"gitea_path" json:"gitea_path"`
|
|
SectionSpecRaw []byte `db:"section_spec" json:"-"`
|
|
IsDefaultForRaw pq.StringArray `db:"is_default_for" json:"-"`
|
|
IsActive bool `db:"is_active" json:"is_active"`
|
|
|
|
// SectionSpec is the parsed section spec; populated on read by the
|
|
// service so callers don't have to unmarshal manually.
|
|
SectionSpec BaseSectionSpec `json:"section_spec"`
|
|
|
|
// IsDefaultFor is the parsed string-slice form of the
|
|
// is_default_for column.
|
|
IsDefaultFor []string `json:"is_default_for"`
|
|
}
|
|
|
|
// BaseSectionSpec is the parsed shape of submission_bases.section_spec.
|
|
// Slice A consumes Defaults to seed submission_sections rows on draft
|
|
// create; later slices consume Stylemap (Slice B's MD→OOXML walker) and
|
|
// Version (forward compat).
|
|
type BaseSectionSpec struct {
|
|
Version int `json:"version"`
|
|
Stylemap map[string]string `json:"stylemap"`
|
|
Defaults []BaseSectionSpecDefault `json:"defaults"`
|
|
}
|
|
|
|
// BaseSectionSpecDefault declares one default section per base. SeedMD*
|
|
// is the Markdown copied into submission_sections.content_md_* on draft
|
|
// create. Empty seed = blank prose section.
|
|
type BaseSectionSpecDefault struct {
|
|
SectionKey string `json:"section_key"`
|
|
Kind string `json:"kind"`
|
|
OrderIndex int `json:"order_index"`
|
|
LabelDE string `json:"label_de"`
|
|
LabelEN string `json:"label_en"`
|
|
Included bool `json:"included"`
|
|
SeedMDDE string `json:"seed_md_de"`
|
|
SeedMDEN string `json:"seed_md_en"`
|
|
}
|
|
|
|
// BaseService reads the catalog. No mutations in Slice A.
|
|
type BaseService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// NewBaseService wires the service.
|
|
func NewBaseService(db *sqlx.DB) *BaseService {
|
|
return &BaseService{db: db}
|
|
}
|
|
|
|
// ErrBaseNotFound is the sentinel for "no base with that id/slug".
|
|
var ErrBaseNotFound = errors.New("submission base: not found")
|
|
|
|
const baseColumns = `id, slug, firm, proceeding_family, label_de, label_en,
|
|
description_de, description_en, gitea_path,
|
|
section_spec, is_default_for, is_active`
|
|
|
|
// List returns every active base ordered by firm-then-label.
|
|
// firmFilter (when non-empty) restricts to rows where firm matches OR
|
|
// firm IS NULL — the picker shows the firm's own bases plus the
|
|
// firm-agnostic ones.
|
|
func (s *BaseService) List(ctx context.Context, firmFilter string) ([]SubmissionBase, error) {
|
|
var rows []SubmissionBase
|
|
var err error
|
|
if firmFilter == "" {
|
|
err = s.db.SelectContext(ctx, &rows,
|
|
`SELECT `+baseColumns+`
|
|
FROM paliad.submission_bases
|
|
WHERE is_active
|
|
ORDER BY COALESCE(firm, ''), label_de`)
|
|
} else {
|
|
err = s.db.SelectContext(ctx, &rows,
|
|
`SELECT `+baseColumns+`
|
|
FROM paliad.submission_bases
|
|
WHERE is_active AND (firm = $1 OR firm IS NULL)
|
|
ORDER BY (firm IS NULL), label_de`,
|
|
firmFilter)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list submission bases: %w", err)
|
|
}
|
|
for i := range rows {
|
|
if err := rows[i].decode(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// GetByID fetches one base by uuid.
|
|
func (s *BaseService) GetByID(ctx context.Context, id uuid.UUID) (*SubmissionBase, error) {
|
|
var b SubmissionBase
|
|
err := s.db.GetContext(ctx, &b,
|
|
`SELECT `+baseColumns+`
|
|
FROM paliad.submission_bases
|
|
WHERE id = $1 AND is_active`,
|
|
id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrBaseNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get submission base by id: %w", err)
|
|
}
|
|
if err := b.decode(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &b, nil
|
|
}
|
|
|
|
// GetBySlug fetches one base by stable slug ("hlc-letterhead", …).
|
|
func (s *BaseService) GetBySlug(ctx context.Context, slug string) (*SubmissionBase, error) {
|
|
var b SubmissionBase
|
|
err := s.db.GetContext(ctx, &b,
|
|
`SELECT `+baseColumns+`
|
|
FROM paliad.submission_bases
|
|
WHERE slug = $1 AND is_active`,
|
|
slug)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrBaseNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get submission base by slug: %w", err)
|
|
}
|
|
if err := b.decode(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &b, nil
|
|
}
|
|
|
|
// GetDefaultForCode picks the base SubmissionDraftService.Create should
|
|
// seed for a new draft, given the requesting firm and the draft's
|
|
// submission_code. Priority:
|
|
//
|
|
// 1. firm-matched base whose is_default_for[] contains the exact code.
|
|
// 2. firm-matched base whose proceeding_family matches the code's
|
|
// family (first three dot-segments, e.g. "de.inf.lg" from
|
|
// "de.inf.lg.erwidg").
|
|
// 3. firm-matched base with NULL proceeding_family (firm-agnostic
|
|
// fallback within the firm).
|
|
// 4. firm-NULL (cross-firm) base by family match.
|
|
// 5. firm-NULL base with NULL family — the universal neutral fallback.
|
|
// 6. first active row (deterministic ordering on (firm IS NULL,
|
|
// label_de)).
|
|
//
|
|
// Returns ErrBaseNotFound if the table is empty.
|
|
func (s *BaseService) GetDefaultForCode(ctx context.Context, firm, submissionCode string) (*SubmissionBase, error) {
|
|
family := familyOfCode(submissionCode)
|
|
|
|
tryQueries := []struct {
|
|
sql string
|
|
args []any
|
|
}{
|
|
{
|
|
`SELECT ` + baseColumns + `
|
|
FROM paliad.submission_bases
|
|
WHERE is_active AND firm = $1 AND $2 = ANY(is_default_for)
|
|
ORDER BY label_de LIMIT 1`,
|
|
[]any{firm, submissionCode},
|
|
},
|
|
{
|
|
`SELECT ` + baseColumns + `
|
|
FROM paliad.submission_bases
|
|
WHERE is_active AND firm = $1 AND proceeding_family = $2
|
|
ORDER BY label_de LIMIT 1`,
|
|
[]any{firm, family},
|
|
},
|
|
{
|
|
`SELECT ` + baseColumns + `
|
|
FROM paliad.submission_bases
|
|
WHERE is_active AND firm = $1 AND proceeding_family IS NULL
|
|
ORDER BY label_de LIMIT 1`,
|
|
[]any{firm},
|
|
},
|
|
{
|
|
`SELECT ` + baseColumns + `
|
|
FROM paliad.submission_bases
|
|
WHERE is_active AND firm IS NULL AND proceeding_family = $1
|
|
ORDER BY label_de LIMIT 1`,
|
|
[]any{family},
|
|
},
|
|
{
|
|
`SELECT ` + baseColumns + `
|
|
FROM paliad.submission_bases
|
|
WHERE is_active AND firm IS NULL AND proceeding_family IS NULL
|
|
ORDER BY label_de LIMIT 1`,
|
|
[]any{},
|
|
},
|
|
{
|
|
`SELECT ` + baseColumns + `
|
|
FROM paliad.submission_bases
|
|
WHERE is_active
|
|
ORDER BY (firm IS NULL), label_de LIMIT 1`,
|
|
[]any{},
|
|
},
|
|
}
|
|
|
|
for _, q := range tryQueries {
|
|
var b SubmissionBase
|
|
err := s.db.GetContext(ctx, &b, q.sql, q.args...)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get default base: %w", err)
|
|
}
|
|
if err := b.decode(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &b, nil
|
|
}
|
|
return nil, ErrBaseNotFound
|
|
}
|
|
|
|
// familyOfCode returns the first three dot-segments of a submission_code.
|
|
// "de.inf.lg.erwidg" → "de.inf.lg". Codes with fewer than three segments
|
|
// pass through unchanged (none in the corpus today, but safe).
|
|
func familyOfCode(code string) string {
|
|
parts := strings.SplitN(code, ".", 4)
|
|
if len(parts) <= 3 {
|
|
return code
|
|
}
|
|
return strings.Join(parts[:3], ".")
|
|
}
|
|
|
|
// decode fills the parsed views from the raw scan fields.
|
|
func (b *SubmissionBase) decode() error {
|
|
if len(b.SectionSpecRaw) > 0 {
|
|
if err := json.Unmarshal(b.SectionSpecRaw, &b.SectionSpec); err != nil {
|
|
return fmt.Errorf("decode submission base section_spec: %w", err)
|
|
}
|
|
}
|
|
b.IsDefaultFor = []string(b.IsDefaultForRaw)
|
|
if b.IsDefaultFor == nil {
|
|
b.IsDefaultFor = []string{}
|
|
}
|
|
return nil
|
|
}
|