Quellcode durchsuchen

Sprint settings: secured Delete sprint action

Adds a "Danger zone" section at the bottom of /sprints/{id}/settings
with a type-the-name confirmation form. Removing a sprint takes out
its full subtree, so the path is gated three ways:

 - SessionGuard::requireAdmin + verifyCsrf on the form post
   (POST /sprints/{id}/delete);
 - confirm_name field must match sprint.name verbatim — checked
   server-side as the authoritative gate, plus a JS guard in
   sprint-settings.js that keeps the submit button disabled until
   the typed name matches and a final native window.confirm() on
   submit so a misclick on the now-enabled button can't fire the
   destructive POST;
 - server redirects to /sprints/{id}/settings?error=name_mismatch
   on a JS-bypass attempt where the typed name didn't match, so
   the form posting straight from curl still gets a clean failure
   page rather than a half-committed delete.

Cascade audit (spec §7): SprintController::delete snapshots every
descendant before the FK cascade fires — task_assignments,
sprint_worker_days, tasks, sprint_workers, sprint_weeks — and
records one DELETE row per child plus one for the parent sprint,
all inside the same transaction. Phase 22's
tasks.linked_task_id ON DELETE SET NULL on cross-sprint copies is
also captured: rows in *other* sprints whose linked_task_id pointed
at one of this sprint's tasks get an UPDATE audit (linked_task_id
X → null) so the chain is reconstructable.

On success the user lands at /?deleted=<sprint name> with a green
"Sprint X was deleted." chip rendered by views/home.twig.

SprintRepository gains a delete(int $id): ?Sprint that returns the
pre-deletion snapshot for the parent audit row, mirroring
TaskRepository::delete's contract.

CascadeAuditTest grows a fourth path covering the full sprint
delete: sets up a 2-worker / 4-week / 1-task / 2-assignment fixture,
audits each leaf, drops the sprint, then asserts the per-entity-type
audit count matches the cascade size (2 task_assignment + 8
sprint_worker_days + 1 task + 2 sprint_worker + 4 sprint_week + 1
sprint).

Tests: 149 / 424 (was 148 / 406, +1 path / +18 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa vor 3 Tagen
Ursprung
Commit
8e8b8fd003

+ 32 - 0
public/assets/js/sprint-settings.js

@@ -260,4 +260,36 @@
         if (es && inSprint)  { es.style.display = inSprint.querySelectorAll('li').length === 0 ? '' : 'none'; }
     }
     refreshEmptyStates();
+
+    // ----- Danger zone: type-the-name confirmation gate -----------------
+    //
+    // The submit button stays disabled until the input matches the sprint
+    // name verbatim. SprintController::delete repeats the check server-side
+    // — this is just a UX guard, not the authoritative one.
+    (function () {
+        const form = document.querySelector('[data-delete-sprint-form]');
+        if (!form) { return; }
+        const expected = String(form.getAttribute('data-confirm-name') || '');
+        const input    = form.querySelector('[data-delete-confirm-input]');
+        const btn      = form.querySelector('[data-delete-confirm-btn]');
+        if (!input || !btn) { return; }
+
+        function refresh() {
+            btn.disabled = String(input.value) !== expected;
+        }
+        input.addEventListener('input', refresh);
+        refresh();
+
+        form.addEventListener('submit', function (ev) {
+            if (String(input.value) !== expected) {
+                ev.preventDefault();
+                return;
+            }
+            // Final native confirm so a misclick on the now-enabled button
+            // doesn't fire the destructive POST.
+            if (!window.confirm('Delete sprint "' + expected + '" and all its data? This cannot be undone.')) {
+                ev.preventDefault();
+            }
+        });
+    })();
 })();

+ 2 - 0
public/index.php

@@ -147,6 +147,7 @@ $router->get('/', function (Request $req) use ($view, $pdo, $users, $sprints, $a
         'oidcConfigured'    => OidcClient::isConfigured(),
         'localAdminEnabled' => LocalAdmin::isEnabled(),
         'authError'         => isset($req->query['auth_error']),
+        'deletedSprintName' => $req->queryString('deleted'),
         'csrfToken'         => SessionGuard::csrfToken(),
         'sprintRows'        => $sprintRows,
     ]));
@@ -177,6 +178,7 @@ $router->post('/sprints',                 $sprintCtrl->create(...));
 $router->get('/sprints/{id}',             $sprintCtrl->show(...));
 $router->get('/sprints/{id}/present',     $sprintCtrl->present(...));
 $router->get('/sprints/{id}/settings',    $sprintCtrl->settings(...));
+$router->post('/sprints/{id}/delete',     $sprintCtrl->delete(...));
 
 // JSON mutation endpoints (admin, CSRF via X-CSRF-Token header):
 $router->patch('/sprints/{id}',                       $sprintCtrl->updateMeta(...));

+ 149 - 0
src/Controllers/SprintController.php

@@ -314,6 +314,7 @@ final class SprintController
             'weeks'            => $this->weeks->allForSprint($id),
             'sprintWorkers'    => $this->sprintWorkers->allForSprint($id),
             'availableWorkers' => $this->workers->activeNotInSprint($id),
+            'error'            => $req->queryString('error'),
         ]));
     }
 
@@ -969,6 +970,154 @@ final class SprintController
         return Response::ok(['sprint_week' => $result['after']->toAuditSnapshot()]);
     }
 
+    /**
+     * POST /sprints/{id}/delete — destructive: removes the sprint and every
+     * row attached to it.
+     *
+     * Form-post (not JSON): admin + CSRF token from the form, plus a
+     * `confirm_name` field that must match the sprint's name verbatim. Each
+     * cascaded child (task_assignments, sprint_worker_days, tasks,
+     * sprint_workers, sprint_weeks) is snapshotted and audited DELETE
+     * before the parent delete fires, per spec §7. Tasks in *other*
+     * sprints whose `linked_task_id` points at one of this sprint's tasks
+     * are silently SET NULL by the FK; we audit those as UPDATE rows so
+     * the chain is reconstructable from the audit log.
+     */
+    public function delete(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $sprintId = (int) $params['id'];
+        $sprint   = $this->sprints->find($sprintId);
+        if ($sprint === null) {
+            return Response::text('Not Found', 404);
+        }
+
+        // Type-the-name guard: defence in depth — the JS keeps the submit
+        // button disabled until this matches, but a JS-bypass attempt still
+        // hits this check.
+        $confirm = trim($req->postString('confirm_name'));
+        if ($confirm !== $sprint->name) {
+            return Response::redirect('/sprints/' . $sprintId . '/settings?error=name_mismatch');
+        }
+
+        // Snapshot every cascaded child BEFORE the parent delete. Order
+        // mirrors the FK dependency tree: leaves first.
+        $sprintWorkers       = $this->sprintWorkers->allForSprint($sprintId);
+        $cascadedDays        = [];
+        $cascadedAssignments = [];
+        foreach ($sprintWorkers as $sw) {
+            foreach ($this->days->allForSprintWorker($sw->id) as $d) {
+                $cascadedDays[] = $d;
+            }
+            foreach ($this->assignments->allForSprintWorker($sw->id) as $a) {
+                $cascadedAssignments[] = $a;
+            }
+        }
+        $tasksInSprint = $this->tasks->allForSprint($sprintId);
+        $weeks         = $this->weeks->allForSprint($sprintId);
+
+        // Phase 22 SET NULL audit: tasks in OTHER sprints whose
+        // linked_task_id points at any task in this sprint will be
+        // silently nulled by the cascade. Capture them so the audit log
+        // doesn't lose the link.
+        $linkUpdates = [];
+        if ($tasksInSprint !== []) {
+            $taskIds = array_map(fn($t) => $t->id, $tasksInSprint);
+            $place   = implode(',', array_fill(0, count($taskIds), '?'));
+            $stmt    = $this->pdo->prepare(
+                'SELECT * FROM tasks WHERE linked_task_id IN (' . $place . ')'
+            );
+            $stmt->execute($taskIds);
+            foreach ($stmt as $row) {
+                $tid = (int) $row['id'];
+                // Skip rows that are themselves in this sprint — they're
+                // about to be deleted, no SET NULL audit needed.
+                if ((int) $row['sprint_id'] === $sprintId) {
+                    continue;
+                }
+                $linkUpdates[] = $tid;
+            }
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            // Audit cascaded leaves first.
+            foreach ($cascadedAssignments as $a) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'task_assignment', $a->id,
+                    $a->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+            foreach ($cascadedDays as $d) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'sprint_worker_days', $d->id,
+                    $d->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+            foreach ($tasksInSprint as $t) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'task', $t->id,
+                    $t->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+            foreach ($sprintWorkers as $sw) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'sprint_worker', $sw->id,
+                    $sw->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+            foreach ($weeks as $w) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'sprint_week', $w->id,
+                    $w->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+
+            // Audit the SET NULL on cross-sprint linked tasks. We refetch
+            // each row inside the transaction so the snapshot is current.
+            foreach ($linkUpdates as $tid) {
+                $linked = $this->tasks->find($tid);
+                if ($linked === null) {
+                    continue;
+                }
+                $beforeSnap = $linked->toAuditSnapshot();
+                $afterSnap  = $beforeSnap;
+                $afterSnap['linked_task_id'] = null;
+                $this->audit->recordForRequest(
+                    'UPDATE', 'task', $tid,
+                    $beforeSnap, $afterSnap,
+                    $req, $actor,
+                );
+            }
+
+            $this->sprints->delete($sprintId);
+            $this->audit->recordForRequest(
+                'DELETE', 'sprint', $sprintId,
+                $sprint->toAuditSnapshot(), null,
+                $req, $actor,
+            );
+
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::redirect('/sprints/' . $sprintId . '/settings?error=db_error');
+        }
+
+        return Response::redirect('/?deleted=' . rawurlencode($sprint->name));
+    }
+
     // ------------------------------------------------------------------
     // Shared helpers
     // ------------------------------------------------------------------

+ 19 - 0
src/Repositories/SprintRepository.php

@@ -118,6 +118,25 @@ final class SprintRepository
         return ['before' => $before, 'after' => $after];
     }
 
+    /**
+     * Delete a sprint. Does NOT read cascaded child rows; the controller is
+     * responsible for auditing those (sprint_weeks / sprint_workers /
+     * sprint_worker_days / tasks / task_assignments) BEFORE calling this
+     * method, per spec §7. Returns the pre-deletion row for the parent
+     * audit entry.
+     */
+    public function delete(int $id): ?Sprint
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            return null;
+        }
+        $this->pdo
+            ->prepare('DELETE FROM sprints WHERE id = ?')
+            ->execute([$id]);
+        return $before;
+    }
+
     /**
      * Materialise N week rows for a sprint with sensible defaults.
      *

+ 82 - 0
tests/Cascade/CascadeAuditTest.php

@@ -238,4 +238,86 @@ final class CascadeAuditTest extends TestCase
         $this->assertCount(1, $s['assignments']->allForSprintWorker($s['swBobId']));
         $this->assertSame([], $s['assignments']->allForSprintWorker(999_999));
     }
+
+    // -------------------------------------------------------------------
+    // Path 4 (Phase 22.1): deleting a sprint cascades through the entire
+    // child tree — every leaf gets a DELETE audit before the parent goes.
+    // -------------------------------------------------------------------
+
+    public function testDeletingSprintAuditsEntireCascade(): void
+    {
+        $s = $this->seed();
+        /** @var SprintRepository $sprints */
+        $sprints = new SprintRepository($s['pdo']);
+
+        // Mirror SprintController::delete: snapshot every leaf, then audit
+        // each one, then drop the sprint and audit the parent.
+        $sprintWorkers = $s['sprintWorkers']->allForSprint($s['sprintId']);
+        $cascadedDays  = [];
+        $cascadedAsgs  = [];
+        foreach ($sprintWorkers as $sw) {
+            foreach ($s['days']->allForSprintWorker($sw->id) as $d) {
+                $cascadedDays[] = $d;
+            }
+            foreach ($s['assignments']->allForSprintWorker($sw->id) as $a) {
+                $cascadedAsgs[] = $a;
+            }
+        }
+        $tasksRepo = new TaskRepository($s['pdo']);
+        $tasks     = $tasksRepo->allForSprint($s['sprintId']);
+        $weeks     = $s['weeks']->allForSprint($s['sprintId']);
+
+        $this->assertCount(2, $sprintWorkers);
+        $this->assertCount(8, $cascadedDays,  'Alice + Bob × 4 weeks');
+        $this->assertCount(2, $cascadedAsgs,  'one assignment per worker');
+        $this->assertCount(1, $tasks);
+        $this->assertCount(4, $weeks);
+
+        $s['pdo']->beginTransaction();
+        foreach ($cascadedAsgs as $a) {
+            $s['audit']->record('DELETE', 'task_assignment',    $a->id, $a->toAuditSnapshot(), null);
+        }
+        foreach ($cascadedDays as $d) {
+            $s['audit']->record('DELETE', 'sprint_worker_days', $d->id, $d->toAuditSnapshot(), null);
+        }
+        foreach ($tasks as $t) {
+            $s['audit']->record('DELETE', 'task',               $t->id, $t->toAuditSnapshot(), null);
+        }
+        foreach ($sprintWorkers as $sw) {
+            $s['audit']->record('DELETE', 'sprint_worker',      $sw->id, $sw->toAuditSnapshot(), null);
+        }
+        foreach ($weeks as $w) {
+            $s['audit']->record('DELETE', 'sprint_week',        $w->id, $w->toAuditSnapshot(), null);
+        }
+        $deleted = $sprints->delete($s['sprintId']);
+        $this->assertNotNull($deleted);
+        $s['audit']->record('DELETE', 'sprint', $deleted->id, $deleted->toAuditSnapshot(), null);
+        $s['pdo']->commit();
+
+        // Sprint and every cascaded child are gone.
+        $this->assertNull($sprints->find($s['sprintId']));
+        $this->assertCount(0, $s['weeks']->allForSprint($s['sprintId']));
+        $this->assertCount(0, $s['sprintWorkers']->allForSprint($s['sprintId']));
+        $this->assertCount(0, $tasksRepo->allForSprint($s['sprintId']));
+        $this->assertSame([], $s['days']->allForSprintWorker($s['swAliceId']));
+        $this->assertSame([], $s['assignments']->allForSprintWorker($s['swAliceId']));
+
+        // Audit count per entity_type matches the cascade size.
+        $countByType = [];
+        $stmt = $s['pdo']->query(
+            "SELECT entity_type, COUNT(*) AS n
+             FROM audit_log
+             WHERE action = 'DELETE'
+             GROUP BY entity_type"
+        );
+        foreach ($stmt as $row) {
+            $countByType[(string) $row['entity_type']] = (int) $row['n'];
+        }
+        $this->assertSame(2, $countByType['task_assignment']    ?? 0);
+        $this->assertSame(8, $countByType['sprint_worker_days'] ?? 0);
+        $this->assertSame(1, $countByType['task']               ?? 0);
+        $this->assertSame(2, $countByType['sprint_worker']      ?? 0);
+        $this->assertSame(4, $countByType['sprint_week']        ?? 0);
+        $this->assertSame(1, $countByType['sprint']             ?? 0);
+    }
 }

+ 6 - 0
views/home.twig

@@ -8,6 +8,12 @@
         </div>
     {% endif %}
 
+    {% if deletedSprintName|default('') != '' %}
+        <div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200">
+            Sprint <b>{{ deletedSprintName }}</b> was deleted.
+        </div>
+    {% endif %}
+
     {% if currentUser is null %}
         <div class="rounded-lg border bg-white p-6 dark:bg-slate-800 dark:border-slate-700">
             <h1 class="text-2xl font-semibold tracking-tight">Sprint Planner</h1>

+ 40 - 0
views/sprints/settings.twig

@@ -21,6 +21,16 @@
         </div>
     </header>
 
+    {% if error|default('') == 'name_mismatch' %}
+        <div class="rounded-md border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200">
+            The confirmation name didn't match — sprint was not deleted.
+        </div>
+    {% elseif error|default('') == 'db_error' %}
+        <div class="rounded-md border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200">
+            Could not delete the sprint — please try again.
+        </div>
+    {% endif %}
+
     <section class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Sprint</h2>
         <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
@@ -154,6 +164,36 @@
             RTB (Run-the-Business) is informational and does not reduce computed capacity.
         </p>
     </section>
+
+    <section class="rounded-lg border border-red-300 bg-red-50 p-5 space-y-4 dark:bg-red-950 dark:border-red-800">
+        <h2 class="text-sm font-semibold uppercase tracking-wider text-red-800 dark:text-red-200">
+            Danger zone
+        </h2>
+        <p class="text-sm text-red-800 dark:text-red-200">
+            Deleting this sprint removes <b>everything attached to it</b>: weeks,
+            workers, day cells, tasks, and per-cell assignments. Linked-task
+            references from other sprints will remain visible but no longer
+            point anywhere. <b>This cannot be undone.</b>
+        </p>
+        <form method="post" action="/sprints/{{ sprint.id }}/delete"
+              class="space-y-3 max-w-md"
+              data-delete-sprint-form
+              data-confirm-name="{{ sprint.name }}">
+            <input type="hidden" name="_csrf" value="{{ csrfToken }}">
+            <label class="block text-sm">
+                <span class="block mb-1 text-red-800 dark:text-red-200">
+                    Type <b>{{ sprint.name }}</b> to confirm.
+                </span>
+                <input name="confirm_name" type="text" autocomplete="off" required
+                       data-delete-confirm-input
+                       class="block w-full rounded border border-red-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-400 dark:bg-slate-800 dark:border-red-700 dark:text-slate-100 dark:focus:ring-red-500">
+            </label>
+            <button type="submit" data-delete-confirm-btn disabled
+                    class="rounded bg-red-600 text-white px-3 py-2 text-sm font-medium hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed disabled:hover:bg-slate-300 dark:disabled:bg-slate-700 dark:disabled:text-slate-500">
+                Delete sprint permanently
+            </button>
+        </form>
+    </section>
 </section>
 
 <script src="/assets/js/sprint-settings.js" defer></script>