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
97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
package handlers
|
|
|
|
// Submission base catalog handler — Composer Slice A (t-paliad-313,
|
|
// m/paliad#141, design doc docs/design-submission-generator-v2-2026-05-26.md
|
|
// §5.1 / Slice A acceptance).
|
|
//
|
|
// Endpoint: GET /api/submission-bases → list of active bases visible
|
|
// to the requesting firm. The sidebar picker on the draft editor reads
|
|
// this once on page load and caches in-memory; the response shape is
|
|
// stable across the picker's lifetime.
|
|
//
|
|
// Visibility: the catalog is shared firm-wide (per the design + mig
|
|
// 146's wide-open RLS SELECT policy). The handler still requires
|
|
// authentication; anonymous users 401.
|
|
//
|
|
// Filtering: the response includes the firm's own bases AND the
|
|
// firm-agnostic ones (firm IS NULL). The Go service-side filter passes
|
|
// branding.Name as the firm hint; cross-firm cases (e.g. a future
|
|
// non-HLC deployment) get their own filtered slice naturally.
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/branding"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// submissionBaseRow is the on-the-wire shape returned by the list
|
|
// endpoint. Mirrors services.SubmissionBase but drops the raw bytes
|
|
// and exposes the parsed section spec inline so the picker can show a
|
|
// preview of the default section count without an extra round-trip.
|
|
type submissionBaseRow struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Firm *string `json:"firm,omitempty"`
|
|
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
|
LabelDE string `json:"label_de"`
|
|
LabelEN string `json:"label_en"`
|
|
DescriptionDE *string `json:"description_de,omitempty"`
|
|
DescriptionEN *string `json:"description_en,omitempty"`
|
|
GiteaPath string `json:"gitea_path"`
|
|
IsDefaultFor []string `json:"is_default_for"`
|
|
IsActive bool `json:"is_active"`
|
|
SectionCount int `json:"section_count"`
|
|
}
|
|
|
|
type submissionBaseListResponse struct {
|
|
Bases []submissionBaseRow `json:"bases"`
|
|
}
|
|
|
|
// handleListSubmissionBases backs GET /api/submission-bases.
|
|
func handleListSubmissionBases(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBase == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "submission bases not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
rows, err := dbSvc.submissionBase.List(r.Context(), branding.Name)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
out := make([]submissionBaseRow, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, baseRowFromService(&rows[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, submissionBaseListResponse{Bases: out})
|
|
}
|
|
|
|
// baseRowFromService projects a services.SubmissionBase into the
|
|
// on-the-wire row shape.
|
|
func baseRowFromService(b *services.SubmissionBase) submissionBaseRow {
|
|
return submissionBaseRow{
|
|
ID: b.ID.String(),
|
|
Slug: b.Slug,
|
|
Firm: b.Firm,
|
|
ProceedingFamily: b.ProceedingFamily,
|
|
LabelDE: b.LabelDE,
|
|
LabelEN: b.LabelEN,
|
|
DescriptionDE: b.DescriptionDE,
|
|
DescriptionEN: b.DescriptionEN,
|
|
GiteaPath: b.GiteaPath,
|
|
IsDefaultFor: b.IsDefaultFor,
|
|
IsActive: b.IsActive,
|
|
SectionCount: len(b.SectionSpec.Defaults),
|
|
}
|
|
}
|