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
333 lines
10 KiB
Go
333 lines
10 KiB
Go
package handlers
|
||
|
||
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
|
||
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
|
||
// where the lawyer types prose into each section.
|
||
//
|
||
// Endpoint:
|
||
//
|
||
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
|
||
//
|
||
// Body shape (all fields optional — absent = no change):
|
||
//
|
||
// {
|
||
// "content_md_de": "...",
|
||
// "content_md_en": "...",
|
||
// "included": true|false,
|
||
// "label_de": "...",
|
||
// "label_en": "...",
|
||
// "order_index": 3
|
||
// }
|
||
//
|
||
// Visibility: ownership of the draft is checked via
|
||
// SubmissionDraftService.Get (404 on no-access), then the section is
|
||
// fetched + verified to belong to that draft. The DB-side RLS policy
|
||
// (mig 148) enforces the same gate independently.
|
||
//
|
||
// Returns 200 + the refreshed section row on success.
|
||
//
|
||
// This is global-scoped (no /projects/{id}/ prefix) because the
|
||
// section's owning draft already carries the project_id; routing on
|
||
// section_id alone keeps the URL shape stable across project-scoped
|
||
// and project-less drafts.
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/services"
|
||
)
|
||
|
||
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
|
||
type submissionSectionPatchInput struct {
|
||
ContentMDDE *string `json:"content_md_de,omitempty"`
|
||
ContentMDEN *string `json:"content_md_en,omitempty"`
|
||
Included *bool `json:"included,omitempty"`
|
||
LabelDE *string `json:"label_de,omitempty"`
|
||
LabelEN *string `json:"label_en,omitempty"`
|
||
OrderIndex *int `json:"order_index,omitempty"`
|
||
}
|
||
|
||
// submissionSectionPatchTimeout caps the round-trip.
|
||
const submissionSectionPatchTimeout = 10 * time.Second
|
||
|
||
func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||
return
|
||
}
|
||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||
if !ok {
|
||
return
|
||
}
|
||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||
defer cancel()
|
||
|
||
// Owner-scope on the draft (RLS mirror; this gives us the typed
|
||
// 404 + the path for the "section belongs to a different draft"
|
||
// case below).
|
||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||
if err != nil {
|
||
writeSubmissionDraftServiceError(w, err)
|
||
return
|
||
}
|
||
|
||
existing, 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 existing.DraftID != draft.ID {
|
||
// Section exists but doesn't belong to this draft — surface as
|
||
// 404 to keep the "no fishing for foreign drafts" property.
|
||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||
return
|
||
}
|
||
|
||
var input submissionSectionPatchInput
|
||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||
return
|
||
}
|
||
|
||
patch := services.SectionPatch{
|
||
ContentMDDE: input.ContentMDDE,
|
||
ContentMDEN: input.ContentMDEN,
|
||
Included: input.Included,
|
||
LabelDE: input.LabelDE,
|
||
LabelEN: input.LabelEN,
|
||
OrderIndex: input.OrderIndex,
|
||
}
|
||
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
|
||
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
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Slice F — add custom section / delete section / reorder
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
type submissionSectionCreateInput struct {
|
||
SectionKey string `json:"section_key"`
|
||
Kind string `json:"kind"`
|
||
LabelDE string `json:"label_de"`
|
||
LabelEN string `json:"label_en"`
|
||
ContentMDDE string `json:"content_md_de,omitempty"`
|
||
ContentMDEN string `json:"content_md_en,omitempty"`
|
||
OrderIndex int `json:"order_index,omitempty"`
|
||
}
|
||
|
||
// handleCreateSubmissionSection backs POST /api/submission-drafts/{draft_id}/sections.
|
||
// Adds a new (custom) section to the draft. Owner-scoped via
|
||
// SubmissionDraftService.Get.
|
||
func handleCreateSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||
return
|
||
}
|
||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||
defer cancel()
|
||
|
||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||
writeSubmissionDraftServiceError(w, err)
|
||
return
|
||
}
|
||
|
||
var input submissionSectionCreateInput
|
||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||
return
|
||
}
|
||
created, err := dbSvc.submissionSection.Create(ctx, services.SectionCreateInput{
|
||
DraftID: draftID,
|
||
SectionKey: input.SectionKey,
|
||
Kind: input.Kind,
|
||
LabelDE: input.LabelDE,
|
||
LabelEN: input.LabelEN,
|
||
ContentMDDE: input.ContentMDDE,
|
||
ContentMDEN: input.ContentMDEN,
|
||
OrderIndex: input.OrderIndex,
|
||
Included: true,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, services.ErrInvalidInput) {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, sectionJSONFromService(created))
|
||
}
|
||
|
||
// handleDeleteSubmissionSection backs DELETE /api/submission-drafts/{draft_id}/sections/{section_id}.
|
||
// Owner-scoped via SubmissionDraftService.Get + section-belongs-to-draft cross-check.
|
||
func handleDeleteSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||
return
|
||
}
|
||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||
if !ok {
|
||
return
|
||
}
|
||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||
if !ok {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||
defer cancel()
|
||
|
||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||
if err != nil {
|
||
writeSubmissionDraftServiceError(w, err)
|
||
return
|
||
}
|
||
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 sec.DraftID != draft.ID {
|
||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||
return
|
||
}
|
||
if err := dbSvc.submissionSection.Delete(ctx, sectionID); err != nil {
|
||
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.StatusNoContent, nil)
|
||
}
|
||
|
||
type submissionSectionReorderInput struct {
|
||
SectionOrder []string `json:"section_order"`
|
||
}
|
||
|
||
// handleReorderSubmissionSections backs POST /api/submission-drafts/{draft_id}/sections/reorder.
|
||
// Accepts a sequence of section_ids; rewrites every row's order_index
|
||
// to (1, 2, 3, …) × 10 in the supplied order. Returns the refreshed
|
||
// section list.
|
||
func handleReorderSubmissionSections(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||
return
|
||
}
|
||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||
if !ok {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||
defer cancel()
|
||
|
||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||
writeSubmissionDraftServiceError(w, err)
|
||
return
|
||
}
|
||
|
||
var input submissionSectionReorderInput
|
||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||
return
|
||
}
|
||
order := make([]uuid.UUID, 0, len(input.SectionOrder))
|
||
for _, raw := range input.SectionOrder {
|
||
id, err := uuid.Parse(raw)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid section id in order list"})
|
||
return
|
||
}
|
||
order = append(order, id)
|
||
}
|
||
|
||
rows, err := dbSvc.submissionSection.Reorder(ctx, draftID, order)
|
||
if err != nil {
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
out := make([]submissionSectionJSON, 0, len(rows))
|
||
for _, sec := range rows {
|
||
out = append(out, sectionJSONFromService(&sec))
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
|
||
}
|
||
|
||
// sectionJSONFromService projects a services.SubmissionSection into the
|
||
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
||
// emits under .sections[].
|
||
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
|
||
return submissionSectionJSON{
|
||
ID: sec.ID,
|
||
SectionKey: sec.SectionKey,
|
||
OrderIndex: sec.OrderIndex,
|
||
Kind: sec.Kind,
|
||
LabelDE: sec.LabelDE,
|
||
LabelEN: sec.LabelEN,
|
||
Included: sec.Included,
|
||
ContentMDDE: sec.ContentMDDE,
|
||
ContentMDEN: sec.ContentMDEN,
|
||
}
|
||
}
|