feat(submissions): Composer Slice F — section reorder / hide / add custom (m/paliad#141)
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

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
This commit is contained in:
mAi
2026-05-26 20:26:53 +02:00
parent 946f373651
commit bd7896ef68
6 changed files with 788 additions and 0 deletions

View File

@@ -178,6 +178,130 @@ func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch
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

View File

@@ -0,0 +1,152 @@
package services
// Live-DB tests for Slice F section service additions (Create + Delete
// + Reorder). Gated on TEST_DATABASE_URL, mirroring Slice A's pattern.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSectionService_SliceF(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()
bases := NewBaseService(pool)
sections := NewSectionService(pool)
// Seed user + draft so we have a draft_id to attach sections to.
userID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
email := "slice-f-" + userID.String()[:8] + "@hlc.com"
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Slice F User', 'munich', 'standard', 'de')`,
userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
drafts.AttachComposer(bases, sections, "HLC")
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("Create draft: %v", err)
}
initial, err := sections.ListForDraft(ctx, d.ID)
if err != nil {
t.Fatalf("ListForDraft initial: %v", err)
}
if len(initial) != 10 {
t.Fatalf("expected 10 seeded sections; got %d", len(initial))
}
t.Run("Create custom section", func(t *testing.T) {
created, err := sections.Create(ctx, SectionCreateInput{
DraftID: d.ID,
SectionKey: "berufungsantraege",
Kind: "requests",
LabelDE: "Berufungsanträge",
LabelEN: "Appeal requests",
Included: true,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if created.OrderIndex <= 10 {
t.Errorf("auto-assigned order_index should be > existing max; got %d", created.OrderIndex)
}
// Slug collision must surface as ErrInvalidInput.
_, err = sections.Create(ctx, SectionCreateInput{
DraftID: d.ID, SectionKey: "berufungsantraege",
Kind: "prose", LabelDE: "x", LabelEN: "x", Included: true,
})
if err == nil {
t.Errorf("expected unique-key collision error; got nil")
}
})
t.Run("Delete section", func(t *testing.T) {
// Grab one of the seeded rows to delete.
current, _ := sections.ListForDraft(ctx, d.ID)
var victimID uuid.UUID
for _, s := range current {
if s.SectionKey == "exhibits" {
victimID = s.ID
break
}
}
if victimID == uuid.Nil {
t.Fatalf("expected exhibits section to exist")
}
if err := sections.Delete(ctx, victimID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Second delete returns not-found.
if err := sections.Delete(ctx, victimID); err == nil {
t.Errorf("expected ErrSubmissionSectionNotFound on second delete")
}
})
t.Run("Reorder sections", func(t *testing.T) {
current, _ := sections.ListForDraft(ctx, d.ID)
if len(current) < 3 {
t.Skipf("need at least 3 sections to test reorder; got %d", len(current))
}
// Reverse the order list.
ids := make([]uuid.UUID, 0, len(current))
for i := len(current) - 1; i >= 0; i-- {
ids = append(ids, current[i].ID)
}
reordered, err := sections.Reorder(ctx, d.ID, ids)
if err != nil {
t.Fatalf("Reorder: %v", err)
}
// Verify the first ID in our list now has the lowest order_index.
if reordered[0].ID != ids[0] {
t.Errorf("first ID after reorder = %s; want %s", reordered[0].ID, ids[0])
}
// Order indices should be ascending.
prev := 0
for _, s := range reordered {
if s.OrderIndex <= prev {
t.Errorf("non-ascending order_index after reorder: %d (prev=%d) at %s", s.OrderIndex, prev, s.SectionKey)
}
prev = s.OrderIndex
}
})
}