Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:
- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
/ global.
Schema (mig 149):
- paliad.submission_building_blocks — library catalog. Columns: slug,
firm (NULL = cross-firm), section_key (binds to one section kind),
proceeding_family (NULL = any), title_de/_en + description_de/_en
+ content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
is_published, created_at, updated_at, deleted_at (soft delete).
RLS: coarse-grained SELECT — every authenticated user sees
non-deleted non-private rows + own private rows. Tier-specific
predicate (private/team/firm/global) applied in Go-layer service so
semantics evolve without RLS migrations. Mutations admin-only (no
RLS write paths).
- paliad.submission_building_block_admin_versions — append-only
history per block, retention=20. Admin-side only; NOT referenced
from submission_sections (per Q2's plain-text-paste model). Exists
so accidental delete / overwrite are recoverable.
Backend:
- internal/services/submission_building_block_service.go (~510 LoC):
BuildingBlockService. ListVisible applies tier predicate at query
time (private = author_id match; firm = firm column NULL OR matches
branding.Name; team = author shares a project_team with caller via
paliad.project_teams self-join; global = open). ListAllForAdmin
drops the predicate. Create + Update + SoftDelete + RestoreVersion
all transactional; appendVersionTx writes one audit row +
GC-deletes anything past the retention=20 horizon in the same tx.
InsertIntoSection (the paste mechanic) clones content_md_<lang>
into the section row with a "\n\n" separator if section already has
content. NO building_block_id stamped per Q2.
- internal/handlers/submission_building_blocks.go (~480 LoC): nine
handlers split between the lawyer-facing picker (list, insert) and
the admin editor (list, get, create, update, delete, list-versions,
restore-version, page). buildingBlockUpdateInput uses presence-
tracking UnmarshalJSON for the four nullable fields (firm,
proceeding_family, description_de/_en) so PATCH can distinguish
"no change" from "set to null".
- Routes registered: lawyer-facing under /api/submission-building-blocks,
admin-gated under /api/admin/submission-building-blocks/* and
/admin/submission-building-blocks (page).
- Wiring: handlers.Services + dbServices + cmd/server/main.go all
gain SubmissionBuildingBlock. NewBuildingBlockService takes the
branding.Name firm hint for the visibility predicate.
Frontend:
- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
three-pane admin shell (list / editor / version log) registered
in build.ts.
- frontend/src/client/admin-submission-building-blocks.ts (~370
LoC): admin client — list paint, edit form (slug + firm +
section_key + proceeding_family + title/desc/content per lang +
visibility radio + is_published toggle), per-block version log
with restore button. Bilingual labels.
- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
button on the Composer editor toolbar (Slice B substrate gets one
more affordance). openBlockPicker opens a modal filtered to the
section's section_key, 200ms-debounced search by free text against
title/description/content. Click a hit → POST insert-into-section
→ section row's content_md_<lang> gains the block's content
appended at the end (Q2's plain-text paste semantic, no lineage).
- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
visibility chips + admin editor 3-pane grid + form rows + version
list.
- 12 new i18n keys × 2 langs (admin.building_blocks.*).
Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
against drift (RLS predicate + DB CHECK depend on them).
Build hygiene: go build/vet/test -short clean; bun run build clean
(2906 i18n keys, data-i18n scan clean).
Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
v1 placeholder pass on export as section prose).
NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
Slice D's job; this Slice doesn't extend the MD walker.
t-paliad-315 Slice C
483 lines
16 KiB
Go
483 lines
16 KiB
Go
package handlers
|
|
|
|
// Composer building-block handlers — t-paliad-315 Slice C.
|
|
//
|
|
// Two surfaces:
|
|
//
|
|
// 1. Lawyer-facing picker (any authenticated user):
|
|
// GET /api/submission-building-blocks?section_key=…&proceeding_family=…&q=…
|
|
// POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}
|
|
//
|
|
// The picker list is visibility-tier-filtered (private/team/firm/
|
|
// global) at the service layer. Insert is the paste mechanic
|
|
// ratified by Q2 (m, 2026-05-26): plain text copy of
|
|
// content_md_<lang> into submission_sections.content_md_<lang>.
|
|
// No lineage stamped on the section.
|
|
//
|
|
// 2. Admin editor (adminGate via auth.RequireAdminFunc):
|
|
// GET /api/admin/submission-building-blocks
|
|
// POST /api/admin/submission-building-blocks
|
|
// GET /api/admin/submission-building-blocks/{block_id}
|
|
// PATCH /api/admin/submission-building-blocks/{block_id}
|
|
// DELETE /api/admin/submission-building-blocks/{block_id}
|
|
// GET /api/admin/submission-building-blocks/{block_id}/versions
|
|
// POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}
|
|
//
|
|
// Plus the page route /admin/submission-building-blocks (list +
|
|
// edit shell, hydrated client-side).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// blockJSON is the on-the-wire shape for both the picker and admin
|
|
// surfaces.
|
|
type buildingBlockJSON struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Firm *string `json:"firm,omitempty"`
|
|
SectionKey string `json:"section_key"`
|
|
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
|
TitleDE string `json:"title_de"`
|
|
TitleEN string `json:"title_en"`
|
|
DescriptionDE *string `json:"description_de,omitempty"`
|
|
DescriptionEN *string `json:"description_en,omitempty"`
|
|
ContentMDDE string `json:"content_md_de"`
|
|
ContentMDEN string `json:"content_md_en"`
|
|
AuthorID *uuid.UUID `json:"author_id,omitempty"`
|
|
Visibility string `json:"visibility"`
|
|
IsPublished bool `json:"is_published"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type buildingBlockListResponse struct {
|
|
Blocks []buildingBlockJSON `json:"blocks"`
|
|
}
|
|
|
|
// blockJSONFromService projects services.BuildingBlock into the wire shape.
|
|
func blockJSONFromService(b *services.BuildingBlock) buildingBlockJSON {
|
|
return buildingBlockJSON{
|
|
ID: b.ID,
|
|
Slug: b.Slug,
|
|
Firm: b.Firm,
|
|
SectionKey: b.SectionKey,
|
|
ProceedingFamily: b.ProceedingFamily,
|
|
TitleDE: b.TitleDE,
|
|
TitleEN: b.TitleEN,
|
|
DescriptionDE: b.DescriptionDE,
|
|
DescriptionEN: b.DescriptionEN,
|
|
ContentMDDE: b.ContentMDDE,
|
|
ContentMDEN: b.ContentMDEN,
|
|
AuthorID: b.AuthorID,
|
|
Visibility: b.Visibility,
|
|
IsPublished: b.IsPublished,
|
|
CreatedAt: b.CreatedAt,
|
|
UpdatedAt: b.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Lawyer-facing picker
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
func handleListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
filter := services.BlockListFilter{
|
|
SectionKey: strings.TrimSpace(q.Get("section_key")),
|
|
ProceedingFamily: strings.TrimSpace(q.Get("proceeding_family")),
|
|
Search: strings.TrimSpace(q.Get("q")),
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
rows, err := dbSvc.submissionBuildingBlock.ListVisible(ctx, uid, filter)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
out := make([]buildingBlockJSON, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, blockJSONFromService(&rows[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
|
|
}
|
|
|
|
func handleInsertBlockIntoSection(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil || dbSvc.submissionSection == nil || dbSvc.submissionDraft == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
|
if !ok {
|
|
return
|
|
}
|
|
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Visibility on the section: section.draft_id must point to a
|
|
// draft the caller owns. Composer Slice B's same owner gate.
|
|
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if _, err := dbSvc.submissionDraft.Get(ctx, uid, sec.DraftID); err != nil {
|
|
writeSubmissionDraftServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
updated, err := dbSvc.submissionBuildingBlock.InsertIntoSection(ctx, uid, blockID, sectionID, dbSvc.submissionSection)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Admin editor
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
func handleAdminListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
rows, err := dbSvc.submissionBuildingBlock.ListAllForAdmin(ctx)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
out := make([]buildingBlockJSON, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, blockJSONFromService(&rows[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
|
|
}
|
|
|
|
func handleAdminGetBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
b, err := dbSvc.submissionBuildingBlock.GetForAdmin(ctx, blockID)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
|
}
|
|
|
|
type buildingBlockCreateInput struct {
|
|
Slug string `json:"slug"`
|
|
Firm *string `json:"firm,omitempty"`
|
|
SectionKey string `json:"section_key"`
|
|
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
|
TitleDE string `json:"title_de"`
|
|
TitleEN string `json:"title_en"`
|
|
DescriptionDE *string `json:"description_de,omitempty"`
|
|
DescriptionEN *string `json:"description_en,omitempty"`
|
|
ContentMDDE string `json:"content_md_de"`
|
|
ContentMDEN string `json:"content_md_en"`
|
|
Visibility string `json:"visibility"`
|
|
IsPublished bool `json:"is_published"`
|
|
}
|
|
|
|
func handleAdminCreateBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
var in buildingBlockCreateInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
b, err := dbSvc.submissionBuildingBlock.Create(ctx, uid, services.CreateInput{
|
|
Slug: in.Slug,
|
|
Firm: in.Firm,
|
|
SectionKey: in.SectionKey,
|
|
ProceedingFamily: in.ProceedingFamily,
|
|
TitleDE: in.TitleDE,
|
|
TitleEN: in.TitleEN,
|
|
DescriptionDE: in.DescriptionDE,
|
|
DescriptionEN: in.DescriptionEN,
|
|
ContentMDDE: in.ContentMDDE,
|
|
ContentMDEN: in.ContentMDEN,
|
|
Visibility: in.Visibility,
|
|
IsPublished: in.IsPublished,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrInvalidInput) || errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, blockJSONFromService(b))
|
|
}
|
|
|
|
type buildingBlockUpdateInput struct {
|
|
Slug *string `json:"slug,omitempty"`
|
|
Firm *string `json:"firm,omitempty"`
|
|
FirmSet bool `json:"-"`
|
|
SectionKey *string `json:"section_key,omitempty"`
|
|
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
|
ProceedingFamilySet bool `json:"-"`
|
|
TitleDE *string `json:"title_de,omitempty"`
|
|
TitleEN *string `json:"title_en,omitempty"`
|
|
DescriptionDE *string `json:"description_de,omitempty"`
|
|
DescriptionDESet bool `json:"-"`
|
|
DescriptionEN *string `json:"description_en,omitempty"`
|
|
DescriptionENSet bool `json:"-"`
|
|
ContentMDDE *string `json:"content_md_de,omitempty"`
|
|
ContentMDEN *string `json:"content_md_en,omitempty"`
|
|
Visibility *string `json:"visibility,omitempty"`
|
|
IsPublished *bool `json:"is_published,omitempty"`
|
|
Note *string `json:"note,omitempty"`
|
|
}
|
|
|
|
func (u *buildingBlockUpdateInput) UnmarshalJSON(data []byte) error {
|
|
type alias buildingBlockUpdateInput
|
|
var a alias
|
|
if err := json.Unmarshal(data, &a); err != nil {
|
|
return err
|
|
}
|
|
*u = buildingBlockUpdateInput(a)
|
|
raw := map[string]json.RawMessage{}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
_, u.FirmSet = raw["firm"]
|
|
_, u.ProceedingFamilySet = raw["proceeding_family"]
|
|
_, u.DescriptionDESet = raw["description_de"]
|
|
_, u.DescriptionENSet = raw["description_en"]
|
|
return nil
|
|
}
|
|
|
|
func handleAdminUpdateBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var in buildingBlockUpdateInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
patch := services.UpdatePatch{
|
|
Slug: in.Slug,
|
|
SectionKey: in.SectionKey,
|
|
TitleDE: in.TitleDE,
|
|
TitleEN: in.TitleEN,
|
|
ContentMDDE: in.ContentMDDE,
|
|
ContentMDEN: in.ContentMDEN,
|
|
Visibility: in.Visibility,
|
|
IsPublished: in.IsPublished,
|
|
Note: in.Note,
|
|
}
|
|
if in.FirmSet {
|
|
patch.Firm = &in.Firm
|
|
}
|
|
if in.ProceedingFamilySet {
|
|
patch.ProceedingFamily = &in.ProceedingFamily
|
|
}
|
|
if in.DescriptionDESet {
|
|
patch.DescriptionDE = &in.DescriptionDE
|
|
}
|
|
if in.DescriptionENSet {
|
|
patch.DescriptionEN = &in.DescriptionEN
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
b, err := dbSvc.submissionBuildingBlock.Update(ctx, uid, blockID, patch)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
|
}
|
|
|
|
func handleAdminDeleteBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
if err := dbSvc.submissionBuildingBlock.SoftDelete(ctx, uid, blockID); err != nil {
|
|
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusNoContent, nil)
|
|
}
|
|
|
|
func handleAdminListBuildingBlockVersions(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
rows, err := dbSvc.submissionBuildingBlock.ListVersions(ctx, blockID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"versions": rows})
|
|
}
|
|
|
|
func handleAdminRestoreBuildingBlockVersion(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.submissionBuildingBlock == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
|
return
|
|
}
|
|
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
|
if !ok {
|
|
return
|
|
}
|
|
versionID, ok := parseUUIDPath(w, r, "version_id", "version id")
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
b, err := dbSvc.submissionBuildingBlock.RestoreVersion(ctx, uid, blockID, versionID)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block or version not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
|
}
|
|
|
|
// handleAdminBuildingBlocksPage serves the admin editor shell. The
|
|
// client bundle hydrates the list + edit UI.
|
|
func handleAdminBuildingBlocksPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin-submission-building-blocks.html")
|
|
}
|