Files
paliad/internal/handlers/submission_sections.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

333 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
}