Files
paliad/internal/services/submission_section_service.go
mAi bd7896ef68
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(submissions): Composer Slice F — section reorder / hide / add custom (m/paliad#141)
The final Composer slice per design doc §12. Lawyer gains full
control over section composition: drag-and-drop reorder, per-section
delete, "+ Add section" picker for custom slugs that don't appear in
the base's default spec. Combined with Slice B's hide toggle, this
closes out the A→F sequence — Composer A→F is complete.

Backend (internal/services/submission_section_service.go, +120 LoC):

- SectionService.Create — adds a new section row to a draft. Validates
  section_key + labels + kind (must be prose/requests/evidence).
  Auto-assigns next order_index when OrderIndex=0; collisions on
  (draft_id, section_key) surface as ErrInvalidInput.

- SectionService.Delete — removes one section by id. Returns
  ErrSubmissionSectionNotFound when nothing was deleted.

- SectionService.Reorder — accepts a sequence of section_ids, rewrites
  every row's order_index to (1..N)×10 transactionally. Returns the
  refreshed list. Sections not present in the sequence are silently
  ignored (defensive — partial reorder doesn't lose rows).

Handlers (internal/handlers/submission_sections.go, +180 LoC):

- POST /api/submission-drafts/{draft_id}/sections — owner-scoped via
  SubmissionDraftService.Get. 400 on slug collision / invalid kind.
- DELETE /api/submission-drafts/{draft_id}/sections/{section_id} —
  owner + section-belongs-to-draft cross-check. 204 on success.
- POST /api/submission-drafts/{draft_id}/sections/reorder — accepts
  {"section_order": [uuid, uuid, ...]}; returns refreshed sections list.

Frontend (frontend/src/client/submission-draft.ts, +260 LoC):

- Each section row gains a drag handle (⋮⋮) on the left of the head.
  Drag handle is the only draggable element; contentEditable
  selections inside the editor body keep working. HTML5 native DnD,
  no library.
- Drop-target highlighting via .submission-draft-section--drop-target
  (border-top accent). Cleanup on dragend / drop / cancel.
- Per-section "Delete" button next to the existing Hide/Include
  toggle. Confirm prompt prevents accidental loss of typed prose.
- "+ Add section" trailing affordance below the section list opens an
  inline form (slug + DE label + EN label + kind dropdown). Submit
  POSTs to the new endpoint; on success splices the row into
  state.view.sections and re-paints.

CSS (frontend/src/styles/global.css, +65 LoC):

- .submission-draft-section-handle (grab cursor + hover background +
  active=grabbing).
- .submission-draft-section--dragging / --drop-target visual states.
- .submission-draft-add-section form layout (dashed border + lime
  primary submit).

Tests (internal/services/submission_section_slice_f_test.go, NEW,
TEST_DATABASE_URL-gated):
- Create custom section + slug-collision surface as ErrInvalidInput.
- Delete + repeat-delete returns ErrSubmissionSectionNotFound.
- Reorder reverses 10 seeded sections + verifies the resulting
  order_index sequence is ascending and matches the input order.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean (2906 i18n keys, data-i18n scan clean).

Hard rules honoured:
- NO new migrations (Slice F is pure code on Slice A's schema).
- NO behavior change for pre-Composer drafts (no section rows → no
  drag handles to drag).
- {{rule.X}} aliases preserved (custom sections render through the
  same composer pipeline as default sections).
- Q2/Q9/Q10 ratifications preserved.

This closes the Composer slice sequence A → F. The full feature set
ratified by m on 2026-05-26 is now in place:
  A — base picker + read-only section list (mig 146/147/148)
  B — editable prose + anchor-spliced render + MD→OOXML walker
  C — building-blocks library + section picker (mig 149)
  D — rich prose (headings, lists, blockquote, hyperlinks)
  E — specialist bases lg-duesseldorf + upc-formal (mig 150)
  F — section reorder / delete / add custom

t-paliad-318 Slice F
2026-05-26 20:26:53 +02:00

338 lines
11 KiB
Go

package services
// Submission section service — Composer Slice A (t-paliad-313, design
// doc docs/design-submission-generator-v2-2026-05-26.md §4.3 + §6).
//
// Each row in paliad.submission_sections is one ordered, named block
// inside a Composer draft. Slice A seeds rows on draft create from the
// base's section_spec.defaults and exposes them read-only for the
// editor's section-list pane. Slice B turns them editable, Slice F
// adds reorder/hide/add-custom.
//
// Visibility flows through draft_id → submission_drafts → owner-scoped
// + can_see_project (RLS in mig 148 mirrors the four-policy shape on
// submission_drafts). Service calls go through SubmissionDraftService
// for the visibility gate before touching this table.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// SubmissionSection mirrors a row in paliad.submission_sections.
type SubmissionSection struct {
ID uuid.UUID `db:"id" json:"id"`
DraftID uuid.UUID `db:"draft_id" json:"draft_id"`
SectionKey string `db:"section_key" json:"section_key"`
OrderIndex int `db:"order_index" json:"order_index"`
Kind string `db:"kind" json:"kind"`
LabelDE string `db:"label_de" json:"label_de"`
LabelEN string `db:"label_en" json:"label_en"`
Included bool `db:"included" json:"included"`
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// SectionService handles per-draft section rows. Slice A: read + seed
// only. Editable mutations land in Slice B's brief.
type SectionService struct {
db *sqlx.DB
}
// NewSectionService wires the service.
func NewSectionService(db *sqlx.DB) *SectionService {
return &SectionService{db: db}
}
// ErrSubmissionSectionNotFound is the sentinel for "no section with
// that id visible to this user".
var ErrSubmissionSectionNotFound = errors.New("submission section: not found")
const sectionColumns = `id, draft_id, section_key, order_index, kind,
label_de, label_en, included,
content_md_de, content_md_en,
created_at, updated_at`
// ListForDraft returns every section row for a draft, ordered by
// order_index ASC. Caller is responsible for the visibility gate
// (SubmissionDraftService.Get returns ErrSubmissionDraftNotFound for
// un-visible drafts, which the handler maps to 404). RLS in mig 148
// additionally enforces owner-scope at the DB layer.
func (s *SectionService) ListForDraft(ctx context.Context, draftID uuid.UUID) ([]SubmissionSection, error) {
var rows []SubmissionSection
err := s.db.SelectContext(ctx, &rows,
`SELECT `+sectionColumns+`
FROM paliad.submission_sections
WHERE draft_id = $1
ORDER BY order_index ASC`,
draftID)
if err != nil {
return nil, fmt.Errorf("list submission sections: %w", err)
}
return rows, nil
}
// Get returns one section by id. Visibility gate is the caller's
// responsibility — Slice A handlers wrap this with a SubmissionDraftService.Get
// to enforce owner+can_see_project before exposing the section.
func (s *SectionService) Get(ctx context.Context, sectionID uuid.UUID) (*SubmissionSection, error) {
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec,
`SELECT `+sectionColumns+`
FROM paliad.submission_sections
WHERE id = $1`,
sectionID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionSectionNotFound
}
if err != nil {
return nil, fmt.Errorf("get submission section: %w", err)
}
return &sec, nil
}
// SectionPatch carries optional fields for an Update call. nil pointer
// = "no change"; non-nil = "set to this".
type SectionPatch struct {
ContentMDDE *string
ContentMDEN *string
Included *bool
LabelDE *string
LabelEN *string
OrderIndex *int
}
// Update applies a patch to one section row. Visibility is the caller's
// responsibility — handlers wrap with SubmissionDraftService.Get for
// owner-scoped checks. The DB-level RLS policy mirrors that check.
//
// Returns the refreshed row. ErrSubmissionSectionNotFound when the
// section doesn't exist or the calling owner can't see it (RLS
// filters at the SELECT step).
func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch SectionPatch) (*SubmissionSection, error) {
setParts := []string{}
args := []any{}
idx := 1
if patch.ContentMDDE != nil {
setParts = append(setParts, fmt.Sprintf("content_md_de = $%d", idx))
args = append(args, *patch.ContentMDDE)
idx++
}
if patch.ContentMDEN != nil {
setParts = append(setParts, fmt.Sprintf("content_md_en = $%d", idx))
args = append(args, *patch.ContentMDEN)
idx++
}
if patch.Included != nil {
setParts = append(setParts, fmt.Sprintf("included = $%d", idx))
args = append(args, *patch.Included)
idx++
}
if patch.LabelDE != nil {
setParts = append(setParts, fmt.Sprintf("label_de = $%d", idx))
args = append(args, *patch.LabelDE)
idx++
}
if patch.LabelEN != nil {
setParts = append(setParts, fmt.Sprintf("label_en = $%d", idx))
args = append(args, *patch.LabelEN)
idx++
}
if patch.OrderIndex != nil {
setParts = append(setParts, fmt.Sprintf("order_index = $%d", idx))
args = append(args, *patch.OrderIndex)
idx++
}
if len(setParts) == 0 {
return s.Get(ctx, sectionID)
}
args = append(args, sectionID)
q := fmt.Sprintf(
`UPDATE paliad.submission_sections
SET %s
WHERE id = $%d
RETURNING `+sectionColumns,
strings.Join(setParts, ", "), idx,
)
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec, q, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionSectionNotFound
}
if err != nil {
return nil, fmt.Errorf("update submission section: %w", err)
}
return &sec, nil
}
// SectionCreateInput is the payload for adding a new (lawyer-custom)
// section to a draft (t-paliad-318 Slice F).
type SectionCreateInput struct {
DraftID uuid.UUID
SectionKey string
Kind string
LabelDE string
LabelEN string
ContentMDDE string
ContentMDEN string
OrderIndex int // 0 = append at end
Included bool // defaults to true if not specified at the handler
}
// Create inserts a new section row for the draft. The section_key
// must not already exist on this draft (UNIQUE constraint at the DB
// catches collisions and surfaces as ErrInvalidInput).
//
// OrderIndex=0 means "auto-assign at the end" — the service queries
// the current max(order_index) and increments. Non-zero values insert
// at the requested position; the caller is responsible for any
// subsequent Reorder if they intend to push existing rows down.
func (s *SectionService) Create(ctx context.Context, in SectionCreateInput) (*SubmissionSection, error) {
in.SectionKey = strings.TrimSpace(in.SectionKey)
in.LabelDE = strings.TrimSpace(in.LabelDE)
in.LabelEN = strings.TrimSpace(in.LabelEN)
if in.SectionKey == "" || in.LabelDE == "" || in.LabelEN == "" {
return nil, ErrInvalidInput
}
switch in.Kind {
case "prose", "requests", "evidence":
default:
return nil, ErrInvalidInput
}
if in.OrderIndex == 0 {
var maxOrder int
err := s.db.GetContext(ctx, &maxOrder,
`SELECT COALESCE(MAX(order_index), 0) FROM paliad.submission_sections WHERE draft_id = $1`,
in.DraftID)
if err != nil {
return nil, fmt.Errorf("max order_index: %w", err)
}
in.OrderIndex = maxOrder + 1
}
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec,
`INSERT INTO paliad.submission_sections
(draft_id, section_key, order_index, kind,
label_de, label_en, included,
content_md_de, content_md_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+sectionColumns,
in.DraftID, in.SectionKey, in.OrderIndex, in.Kind,
in.LabelDE, in.LabelEN, in.Included,
in.ContentMDDE, in.ContentMDEN)
if err != nil {
// UNIQUE (draft_id, section_key) collision → invalid input.
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505") {
return nil, fmt.Errorf("%w: section_key already exists on this draft", ErrInvalidInput)
}
return nil, fmt.Errorf("create submission section: %w", err)
}
return &sec, nil
}
// Delete removes one section row by id. Owner-scope is the caller's
// responsibility (the handler runs SubmissionDraftService.Get first).
func (s *SectionService) Delete(ctx context.Context, sectionID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.submission_sections WHERE id = $1`,
sectionID)
if err != nil {
return fmt.Errorf("delete submission section: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrSubmissionSectionNotFound
}
return nil
}
// Reorder updates the order_index of every section row for the draft
// according to the supplied ID sequence. Transactional — partial
// failures roll back. Any section_id present on the draft but not in
// the sequence keeps its previous order_index, then sorts last by
// updated_at (so a partial reorder doesn't lose rows the caller
// forgot to mention).
func (s *SectionService) Reorder(ctx context.Context, draftID uuid.UUID, order []uuid.UUID) ([]SubmissionSection, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("reorder tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// Each id in order gets order_index 10, 20, 30, ... (gaps so a
// future single-row insert doesn't trigger a full reflow). Ids
// not present on the draft are silently ignored.
for i, sectionID := range order {
idx := (i + 1) * 10
_, err := tx.ExecContext(ctx,
`UPDATE paliad.submission_sections
SET order_index = $1
WHERE id = $2 AND draft_id = $3`,
idx, sectionID, draftID)
if err != nil {
return nil, fmt.Errorf("reorder update: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit reorder: %w", err)
}
committed = true
return s.ListForDraft(ctx, draftID)
}
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
// submission_sections for the given draft. Runs inside the caller's
// transaction (the SubmissionDraftService.Create path wraps the
// draft INSERT + section seed in one tx so a failed seed rolls back
// the draft too).
//
// Idempotent at the row level — UNIQUE (draft_id, section_key) returns
// an error if the seed runs twice for the same draft, which is the
// desired safety net (we never want to silently double-seed).
//
// Per the Q10 ratification: every kind is one of prose | requests |
// evidence — there is no *_auto kind. Caption/letterhead/signature
// sections are regular prose rows seeded with bag-driven Markdown.
func (s *SectionService) SeedFromSpec(ctx context.Context, tx *sqlx.Tx, draftID uuid.UUID, spec BaseSectionSpec) error {
if len(spec.Defaults) == 0 {
return nil
}
for _, d := range spec.Defaults {
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.submission_sections
(draft_id, section_key, order_index, kind,
label_de, label_en, included,
content_md_de, content_md_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
draftID, d.SectionKey, d.OrderIndex, d.Kind,
d.LabelDE, d.LabelEN, d.Included,
d.SeedMDDE, d.SeedMDEN)
if err != nil {
return fmt.Errorf("seed submission section %s: %w", d.SectionKey, err)
}
}
return nil
}