diff --git a/frontend/src/client/submission-draft.ts b/frontend/src/client/submission-draft.ts index 523d7bb..228f875 100644 --- a/frontend/src/client/submission-draft.ts +++ b/frontend/src/client/submission-draft.ts @@ -1317,6 +1317,26 @@ function paintSectionList(): void { for (const sec of sections) { list.appendChild(renderSectionRow(sec, lang, activeID === sec.id)); } + + // t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing + // affordance + "Reihenfolge speichern" affordance (only visible + // after a manual reorder; surfaced by paintReorderControls when + // pendingReorder is set). + let trailer = document.getElementById("submission-draft-sections-trailer"); + if (!trailer) { + trailer = document.createElement("div"); + trailer.id = "submission-draft-sections-trailer"; + trailer.className = "submission-draft-sections-trailer"; + wrap.appendChild(trailer); + } + trailer.innerHTML = ""; + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "btn-small btn-secondary"; + addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen"; + addBtn.addEventListener("click", () => openAddSectionForm(trailer!)); + trailer.appendChild(addBtn); } function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement { @@ -1325,9 +1345,29 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo li.dataset.sectionId = sec.id; if (!sec.included) li.classList.add("submission-draft-section--excluded"); + // t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD, + // no external library. The drag handle is the only draggable + // affordance so clicks inside the editor area don't accidentally + // trigger a drag. + li.draggable = false; // overridden via the handle below + li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li)); + li.addEventListener("drop", (ev) => onSectionDrop(ev, li)); + li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target")); + const head = document.createElement("header"); head.className = "submission-draft-section-head"; + // Drag handle — making just this element draggable scoped the + // gesture so contentEditable selections still work. + const handle = document.createElement("span"); + handle.className = "submission-draft-section-handle"; + handle.draggable = true; + handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen"; + handle.textContent = "⋮⋮"; + handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id)); + handle.addEventListener("dragend", () => onSectionDragEnd(li)); + head.appendChild(handle); + const title = document.createElement("h3"); title.className = "submission-draft-section-title"; title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key; @@ -1356,6 +1396,16 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo toggle.addEventListener("click", () => onSectionToggleIncluded(sec)); head.appendChild(toggle); + // t-paliad-318 Slice F — per-section delete. Removes the row. + // Confirmation guard prevents accidental loss of typed prose. + const del = document.createElement("button"); + del.type = "button"; + del.className = "btn-small btn-link-danger submission-draft-section-delete"; + del.textContent = isEN() ? "Delete" : "Entfernen"; + del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen"; + del.addEventListener("click", () => onSectionDelete(sec)); + head.appendChild(del); + li.appendChild(head); // Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 + @@ -1614,6 +1664,214 @@ async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise { + el.classList.remove("submission-draft-section--drop-target"); + }); + dragSourceID = null; +} + +async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise { + ev.preventDefault(); + targetLi.classList.remove("submission-draft-section--drop-target"); + const sourceID = dragSourceID; + dragSourceID = null; + document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => { + el.classList.remove("submission-draft-section--dragging"); + }); + if (!sourceID || !state.view?.sections) return; + const targetID = targetLi.dataset.sectionId; + if (!targetID || sourceID === targetID) return; + + const ids = state.view.sections.map(s => s.id); + const fromIdx = ids.indexOf(sourceID); + const toIdx = ids.indexOf(targetID); + if (fromIdx < 0 || toIdx < 0) return; + + // Splice source out, insert at target position. "Drop on row X" + // semantics: source lands JUST BEFORE the target row. + ids.splice(fromIdx, 1); + const insertAt = ids.indexOf(targetID); + ids.splice(insertAt, 0, sourceID); + + await reorderSections(ids); +} + +async function reorderSections(ids: string[]): Promise { + if (!state.view) return; + const draftID = state.view.draft.id; + try { + const res = await fetch( + `/api/submission-drafts/${draftID}/sections/reorder`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ section_order: ids }), + }, + ); + if (!res.ok) { + console.warn("reorder failed", res.status); + return; + } + const body = await res.json() as { sections?: SubmissionSectionJSON[] }; + if (state.view && body.sections) state.view.sections = body.sections; + paintSectionList(); + } catch (err) { + console.warn("reorder error", err); + } +} + +async function onSectionDelete(sec: SubmissionSectionJSON): Promise { + const label = isEN() ? sec.label_en : sec.label_de; + const confirmMsg = isEN() + ? `Delete section "${label}"? This cannot be undone.` + : `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`; + if (!confirm(confirmMsg)) return; + if (!state.view) return; + try { + const res = await fetch( + `/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`, + { method: "DELETE", credentials: "include" }, + ); + if (!res.ok && res.status !== 204) { + console.warn("delete section failed", res.status); + return; + } + if (state.view.sections) { + state.view.sections = state.view.sections.filter(s => s.id !== sec.id); + } + paintSectionList(); + } catch (err) { + console.warn("delete section error", err); + } +} + +function openAddSectionForm(host: HTMLElement): void { + // If already open, close (toggle). + const existing = host.querySelector(".submission-draft-add-section"); + if (existing) { + existing.remove(); + return; + } + const form = document.createElement("form"); + form.className = "submission-draft-add-section"; + form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); }); + + const fields = [ + { name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" }, + { name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" }, + { name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" }, + ]; + for (const f of fields) { + const row = document.createElement("label"); + row.className = "submission-draft-add-section-row"; + const lab = document.createElement("span"); + lab.textContent = f.label + (f.required ? " *" : ""); + row.appendChild(lab); + const inp = document.createElement("input"); + inp.type = "text"; + inp.name = f.name; + inp.className = "entity-form-input"; + inp.required = f.required; + inp.placeholder = f.placeholder; + row.appendChild(inp); + form.appendChild(row); + } + + const kindRow = document.createElement("label"); + kindRow.className = "submission-draft-add-section-row"; + const kindLab = document.createElement("span"); + kindLab.textContent = isEN() ? "Kind" : "Typ"; + kindRow.appendChild(kindLab); + const kindSel = document.createElement("select"); + kindSel.name = "kind"; + kindSel.className = "entity-form-input"; + for (const opt of ["prose", "requests", "evidence"]) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + kindSel.appendChild(o); + } + kindRow.appendChild(kindSel); + form.appendChild(kindRow); + + const actions = document.createElement("div"); + actions.className = "submission-draft-add-section-actions"; + const ok = document.createElement("button"); + ok.type = "submit"; + ok.className = "btn-small btn-primary btn-cta-lime"; + ok.textContent = isEN() ? "Add" : "Hinzufügen"; + actions.appendChild(ok); + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "btn-small btn-secondary"; + cancel.textContent = isEN() ? "Cancel" : "Abbrechen"; + cancel.addEventListener("click", () => form.remove()); + actions.appendChild(cancel); + form.appendChild(actions); + + host.appendChild(form); + setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0); +} + +async function submitAddSection(form: HTMLFormElement): Promise { + if (!state.view) return; + const data = new FormData(form); + const payload = { + section_key: String(data.get("section_key") ?? "").trim(), + kind: String(data.get("kind") ?? "prose"), + label_de: String(data.get("label_de") ?? "").trim(), + label_en: String(data.get("label_en") ?? "").trim(), + }; + try { + const res = await fetch( + `/api/submission-drafts/${state.view.draft.id}/sections`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({} as { error?: string })); + alert(body.error ?? `HTTP ${res.status}`); + return; + } + const created = await res.json() as SubmissionSectionJSON; + if (state.view.sections) state.view.sections.push(created); + form.remove(); + paintSectionList(); + } catch (err) { + alert(String(err)); + } +} + // ───────────────────────────────────────────────────────────────────── // t-paliad-315 Slice C — building-block picker modal // ───────────────────────────────────────────────────────────────────── diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 6ade1fa..bc009eb 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -6294,6 +6294,71 @@ dialog.modal::backdrop { background: var(--color-bg-elev-2, var(--color-bg-elev-1)); } +/* t-paliad-318 Slice F — drag-and-drop reorder + add / delete affordances. */ +.submission-draft-section-handle { + cursor: grab; + user-select: none; + color: var(--color-text-muted); + font-weight: 600; + padding: 0 0.35rem; + margin-right: 0.4rem; + border-radius: 3px; +} + +.submission-draft-section-handle:hover { + background: var(--color-bg-subtle, var(--color-bg-elev-2)); +} + +.submission-draft-section-handle:active { + cursor: grabbing; +} + +.submission-draft-section--dragging { + opacity: 0.5; +} + +.submission-draft-section--drop-target { + border-top: 2px solid var(--color-accent-fg, var(--color-text)); +} + +.submission-draft-section-delete { + margin-left: 0.35rem; +} + +.submission-draft-sections-trailer { + margin-top: 0.6rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.submission-draft-add-section { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.6rem 0.7rem; + border: 1px dashed var(--color-border); + border-radius: 4px; + background: var(--color-bg-elev-1); +} + +.submission-draft-add-section-row { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.submission-draft-add-section-row > span { + font-size: 0.85em; + color: var(--color-text-muted); +} + +.submission-draft-add-section-actions { + display: flex; + gap: 0.4rem; + margin-top: 0.2rem; +} + /* t-paliad-315 Slice C — building-block picker modal */ .submission-draft-section-bb-btn { margin-left: auto; diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 395aae4..6edaa19 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -432,6 +432,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc // for inline editor autosave. URL keyed on draft_id + section_id; // owner-scoped via SubmissionDraftService.Get. protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection) + // t-paliad-318 (m/paliad#141) Composer Slice F — add custom + // section, delete section, reorder. + protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections", handleCreateSubmissionSection) + protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}/sections/{section_id}", handleDeleteSubmissionSection) + protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections/reorder", handleReorderSubmissionSections) // t-paliad-315 (m/paliad#141) Composer Slice C — building blocks // library. Lawyer-facing picker + paste mechanic. protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks) diff --git a/internal/handlers/submission_sections.go b/internal/handlers/submission_sections.go index a3bf03a..db03a2b 100644 --- a/internal/handlers/submission_sections.go +++ b/internal/handlers/submission_sections.go @@ -38,6 +38,8 @@ import ( "net/http" "time" + "github.com/google/uuid" + "mgit.msbls.de/m/paliad/internal/services" ) @@ -130,6 +132,188 @@ func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) { 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[]. diff --git a/internal/services/submission_section_service.go b/internal/services/submission_section_service.go index 66bd1c8..49a3a3c 100644 --- a/internal/services/submission_section_service.go +++ b/internal/services/submission_section_service.go @@ -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 diff --git a/internal/services/submission_section_slice_f_test.go b/internal/services/submission_section_slice_f_test.go new file mode 100644 index 0000000..85dbd4f --- /dev/null +++ b/internal/services/submission_section_slice_f_test.go @@ -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 + } + }) +}