|
@@ -314,6 +314,7 @@ final class SprintController
|
|
|
'weeks' => $this->weeks->allForSprint($id),
|
|
'weeks' => $this->weeks->allForSprint($id),
|
|
|
'sprintWorkers' => $this->sprintWorkers->allForSprint($id),
|
|
'sprintWorkers' => $this->sprintWorkers->allForSprint($id),
|
|
|
'availableWorkers' => $this->workers->activeNotInSprint($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()]);
|
|
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
|
|
// Shared helpers
|
|
|
// ------------------------------------------------------------------
|
|
// ------------------------------------------------------------------
|