users); if ($actor instanceof Response) { return $actor; } return Response::html($this->view->render('sprints/new', [ 'title' => 'New sprint', 'currentUser' => $actor, 'csrfToken' => SessionGuard::csrfToken(), 'error' => $req->queryString('error'), 'form' => [ 'name' => '', 'start_date' => '', 'end_date' => '', 'reserve_fraction' => '20', 'n_weeks' => '4', ], ])); } /** POST /sprints — create sprint + materialise weeks in one tx. */ public function create(Request $req): Response { $actor = SessionGuard::requireAdmin($this->users); if ($actor instanceof Response) { return $actor; } if (!SessionGuard::verifyCsrf($req)) { return Response::text('CSRF token invalid', 403); } $name = trim($req->postString('name')); $start = $req->postString('start_date'); $end = $req->postString('end_date'); // reserve_fraction submitted as a percentage (0..100) from the form. $reservePct = $req->postString('reserve_fraction'); $nWeeksStr = $req->postString('n_weeks'); if ($name === '') { return Response::redirect('/sprints/new?error=name_required'); } $startD = DateTimeImmutable::createFromFormat('Y-m-d', $start); $endD = DateTimeImmutable::createFromFormat('Y-m-d', $end); if ($startD === false || $endD === false) { return Response::redirect('/sprints/new?error=dates_invalid'); } if ($endD < $startD) { return Response::redirect('/sprints/new?error=dates_order'); } if (!is_numeric($reservePct)) { return Response::redirect('/sprints/new?error=reserve_invalid'); } $reserve = ((float) $reservePct) / 100.0; if ($reserve < 0.0 || $reserve > 1.0) { return Response::redirect('/sprints/new?error=reserve_out_of_range'); } if (!ctype_digit($nWeeksStr)) { return Response::redirect('/sprints/new?error=n_weeks_invalid'); } $nWeeks = (int) $nWeeksStr; if ($nWeeks < 1 || $nWeeks > 26) { return Response::redirect('/sprints/new?error=n_weeks_range'); } $this->pdo->beginTransaction(); try { $sprint = $this->sprints->create( name: $name, startDate: $startD->format('Y-m-d'), endDate: $endD->format('Y-m-d'), reserveFraction: $reserve, ); $this->audit->recordForRequest( action: 'CREATE', entityType: 'sprint', entityId: $sprint->id, before: null, after: $sprint->toAuditSnapshot(), req: $req, actor: $actor, ); $weeks = $this->sprints->materializeWeeks( $sprint->id, $startD->format('Y-m-d'), $nWeeks, ); foreach ($weeks as $w) { $this->audit->recordForRequest( action: 'CREATE', entityType: 'sprint_week', entityId: $w['id'], before: null, after: ['sprint_id' => $sprint->id] + $w, req: $req, actor: $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::redirect('/sprints/new?error=db_error'); } return Response::redirect('/sprints/' . $sprint->id); } /** GET /sprints/{id} — main planning view (Section A Arbeitstage; tasks land in Phase 6). */ public function show(Request $req, array $params): Response { $actor = SessionGuard::requireAuth($this->users); if ($actor instanceof Response) { return $actor; } $id = (int) $params['id']; $sprint = $this->sprints->find($id); if ($sprint === null) { return Response::text('Not Found', 404); } $weeks = $this->weeks->allForSprint($id); $sprintWorkers = $this->sprintWorkers->allForSprint($id); $grid = $this->days->grid($id); // Seed initial capacity server-side so the page is meaningful without JS // and the JS has the same numbers to compare against. $capacity = []; foreach ($sprintWorkers as $sw) { $wkDays = $grid[$sw->id] ?? []; $ressourcen = array_sum($wkDays); $capacity[$sw->id] = CapacityCalculator::forWorker( $ressourcen, $sprint->reserveFraction, 0.0, // prio-1 commitments come with Phase 6 ); } return Response::html($this->view->render('sprints/show', [ 'title' => $sprint->name, 'currentUser' => $actor, 'csrfToken' => SessionGuard::csrfToken(), 'sprint' => $sprint, 'weeks' => $weeks, 'sprintWorkers' => $sprintWorkers, 'grid' => $grid, 'capacity' => $capacity, ])); } // ----------------------------------------------------------------------- // Phase 4 — settings page + JSON mutation endpoints. // ----------------------------------------------------------------------- /** GET /sprints/{id}/settings — admin-only. */ public function settings(Request $req, array $params): Response { $actor = SessionGuard::requireAdmin($this->users); if ($actor instanceof Response) { return $actor; } $id = (int) $params['id']; $sprint = $this->sprints->find($id); if ($sprint === null) { return Response::text('Not Found', 404); } return Response::html($this->view->render('sprints/settings', [ 'title' => "Settings — {$sprint->name}", 'currentUser' => $actor, 'csrfToken' => SessionGuard::csrfToken(), 'sprint' => $sprint, 'weeks' => $this->weeks->allForSprint($id), 'sprintWorkers' => $this->sprintWorkers->allForSprint($id), 'availableWorkers' => $this->workers->activeNotInSprint($id), ])); } /** PATCH /sprints/{id} — JSON — update name / dates / reserve_fraction. */ public function updateMeta(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $id = (int) $params['id']; $sprint = $this->sprints->find($id); if ($sprint === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json() ?? []; $changes = []; if (array_key_exists('name', $body)) { $name = is_string($body['name']) ? trim($body['name']) : ''; if ($name === '') { return Response::err('validation', 'Name cannot be empty', 422, ['field' => 'name']); } $changes['name'] = $name; } if (array_key_exists('start_date', $body)) { if (!is_string($body['start_date']) || !self::isIsoDate($body['start_date'])) { return Response::err('validation', 'Invalid start_date', 422, ['field' => 'start_date']); } $changes['start_date'] = $body['start_date']; } if (array_key_exists('end_date', $body)) { if (!is_string($body['end_date']) || !self::isIsoDate($body['end_date'])) { return Response::err('validation', 'Invalid end_date', 422, ['field' => 'end_date']); } $changes['end_date'] = $body['end_date']; } if (array_key_exists('reserve_fraction', $body)) { if (!is_numeric($body['reserve_fraction'])) { return Response::err('validation', 'reserve_fraction must be numeric', 422); } $rf = (float) $body['reserve_fraction']; if ($rf < 0.0 || $rf > 1.0) { return Response::err('validation', 'reserve_fraction must be 0..1', 422); } $changes['reserve_fraction'] = $rf; } $effectiveStart = $changes['start_date'] ?? $sprint->startDate; $effectiveEnd = $changes['end_date'] ?? $sprint->endDate; if ($effectiveEnd < $effectiveStart) { return Response::err('validation', 'end_date must be on or after start_date', 422); } if ($changes === []) { return Response::ok(['sprint' => $sprint->toAuditSnapshot()]); } $this->pdo->beginTransaction(); try { $result = $this->sprints->update($id, $changes); $this->audit->recordForRequest( 'UPDATE', 'sprint', $id, $result['before']->toAuditSnapshot(), $result['after']->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not save sprint', 500); } return Response::ok(['sprint' => $result['after']->toAuditSnapshot()]); } /** POST /sprints/{id}/weeks — JSON — resize the week set. */ public function replaceWeeks(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $id = (int) $params['id']; $sprint = $this->sprints->find($id); if ($sprint === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json() ?? []; if (!isset($body['n_weeks']) || !is_int($body['n_weeks']) || $body['n_weeks'] < 1 || $body['n_weeks'] > 26) { return Response::err('validation', 'n_weeks must be an integer in 1..26', 422); } $this->pdo->beginTransaction(); try { $diff = $this->weeks->syncCount($id, $sprint->startDate, (int) $body['n_weeks']); foreach ($diff['added'] as $w) { $this->audit->recordForRequest( 'CREATE', 'sprint_week', $w->id, null, $w->toAuditSnapshot(), $req, $actor, ); } foreach ($diff['removed'] as $w) { $this->audit->recordForRequest( 'DELETE', 'sprint_week', $w->id, $w->toAuditSnapshot(), null, $req, $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not update weeks', 500); } return Response::ok([ 'weeks' => array_map( fn($w) => $w->toAuditSnapshot(), $this->weeks->allForSprint($id) ), 'added' => count($diff['added']), 'removed' => count($diff['removed']), ]); } /** POST /sprints/{id}/workers — JSON — add a worker to the sprint. */ public function addWorker(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $sprintId = (int) $params['id']; if ($this->sprints->find($sprintId) === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json() ?? []; if (!isset($body['worker_id']) || !is_int($body['worker_id'])) { return Response::err('validation', 'worker_id required', 422); } $workerId = (int) $body['worker_id']; $worker = $this->workers->find($workerId); if ($worker === null) { return Response::err('validation', 'Unknown worker', 422, ['field' => 'worker_id']); } if (!$worker->isActive) { return Response::err('validation', 'Worker is inactive', 422, ['field' => 'worker_id']); } $rtb = $worker->defaultRtb; if (isset($body['rtb'])) { if (!is_numeric($body['rtb']) || (float) $body['rtb'] < 0.0 || (float) $body['rtb'] > 1.0) { return Response::err('validation', 'rtb must be 0..1', 422); } $rtb = (float) $body['rtb']; } $this->pdo->beginTransaction(); try { $sw = $this->sprintWorkers->add($sprintId, $workerId, $rtb); $this->audit->recordForRequest( 'CREATE', 'sprint_worker', $sw->id, null, $sw->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (PDOException $e) { $this->pdo->rollBack(); if (str_contains(strtolower($e->getMessage()), 'unique')) { return Response::err('conflict', 'Worker already in sprint', 409); } return Response::err('db_error', 'Could not add worker', 500); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not add worker', 500); } return Response::ok([ 'sprint_worker' => $sw->toAuditSnapshot() + ['worker_name' => $sw->workerName], ]); } /** DELETE /sprints/{id}/workers/{sw_id} — JSON — remove a worker from the sprint. */ public function removeWorker(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $sprintId = (int) $params['id']; $swId = (int) $params['sw_id']; $existing = $this->sprintWorkers->find($swId); if ($existing === null || $existing->sprintId !== $sprintId) { return Response::err('not_found', 'sprint_worker not found in this sprint', 404); } $this->pdo->beginTransaction(); try { $removed = $this->sprintWorkers->remove($swId); if ($removed !== null) { $this->audit->recordForRequest( 'DELETE', 'sprint_worker', $removed->id, $removed->toAuditSnapshot(), null, $req, $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not remove worker', 500); } return Response::ok(['removed_id' => $swId]); } /** POST /sprints/{id}/workers/reorder — JSON — apply an ordering. */ public function reorderWorkers(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $sprintId = (int) $params['id']; if ($this->sprints->find($sprintId) === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json(); if (!is_array($body) || !array_is_list($body)) { return Response::err('validation', 'body must be a list of {sprint_worker_id, sort_order}', 422); } $ordering = []; $seenOrder = []; foreach ($body as $row) { if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sort_order'])) { return Response::err('validation', 'each entry needs sprint_worker_id and sort_order', 422); } $sw = (int) $row['sprint_worker_id']; $order = (int) $row['sort_order']; if ($sw <= 0 || $order < 1) { return Response::err('validation', 'ids/orders must be positive', 422); } if (isset($seenOrder[$order])) { return Response::err('validation', 'duplicate sort_order', 422); } $seenOrder[$order] = true; $ordering[] = ['sprint_worker_id' => $sw, 'sort_order' => $order]; } $this->pdo->beginTransaction(); try { $diffs = $this->sprintWorkers->reorder($sprintId, $ordering); foreach ($diffs as $d) { $this->audit->recordForRequest( 'UPDATE', 'sprint_worker', $d['after']->id, $d['before']->toAuditSnapshot(), $d['after']->toAuditSnapshot(), $req, $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not reorder', 500); } return Response::ok(['moved' => count($diffs)]); } /** PATCH /sprints/{id}/workers/{sw_id} — JSON — edit RTB. */ public function updateWorker(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $sprintId = (int) $params['id']; $swId = (int) $params['sw_id']; $existing = $this->sprintWorkers->find($swId); if ($existing === null || $existing->sprintId !== $sprintId) { return Response::err('not_found', 'sprint_worker not found in this sprint', 404); } $body = $req->json() ?? []; if (!isset($body['rtb']) || !is_numeric($body['rtb'])) { return Response::err('validation', 'rtb required', 422); } $rtb = (float) $body['rtb']; if ($rtb < 0.0 || $rtb > 1.0) { return Response::err('validation', 'rtb must be 0..1', 422); } $this->pdo->beginTransaction(); try { $result = $this->sprintWorkers->setRtb($swId, $rtb); $this->audit->recordForRequest( 'UPDATE', 'sprint_worker', $swId, $result['before']->toAuditSnapshot(), $result['after']->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not update worker', 500); } return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]); } /** PATCH /sprints/{id}/week-cells — JSON — batch upsert of sprint_worker_days. */ public function updateWeekCells(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $sprintId = (int) $params['id']; $sprint = $this->sprints->find($sprintId); if ($sprint === null) { return Response::err('not_found', 'Sprint not found', 404); } $body = $req->json(); if (!is_array($body) || !array_is_list($body)) { return Response::err('validation', 'body must be a list of {sprint_worker_id, sprint_week_id, days}', 422); } if ($body === []) { return Response::ok(['applied' => 0, 'noop' => 0, 'per_worker' => new \stdClass()]); } // Cross-check every cell belongs to this sprint. $validSw = array_column( array_map(fn($sw) => ['id' => $sw->id], $this->sprintWorkers->allForSprint($sprintId)), 'id', ); $validSw = array_flip($validSw); $validWk = array_column( array_map(fn($w) => ['id' => $w->id], $this->weeks->allForSprint($sprintId)), 'id', ); $validWk = array_flip($validWk); $cells = []; foreach ($body as $i => $row) { if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sprint_week_id'], $row['days'])) { return Response::err('validation', "cell[{$i}] needs sprint_worker_id, sprint_week_id, days", 422); } $swId = (int) $row['sprint_worker_id']; $wkId = (int) $row['sprint_week_id']; $daysN = $row['days']; if (!is_numeric($daysN)) { return Response::err('validation', "cell[{$i}] days must be numeric", 422); } $days = (float) $daysN; if (!isset($validSw[$swId])) { return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422); } if (!isset($validWk[$wkId])) { return Response::err('validation', "cell[{$i}] sprint_week {$wkId} not in sprint", 422); } if (!CapacityCalculator::isHalfStep($days, 0.0, 5.0)) { return Response::err('validation', "cell[{$i}] days must be 0..5 in 0.5 steps", 422); } $cells[] = ['sw_id' => $swId, 'week_id' => $wkId, 'days' => $days]; } $applied = 0; $noop = 0; $touchedWorkers = []; $this->pdo->beginTransaction(); try { foreach ($cells as $c) { $result = $this->days->upsert($c['sw_id'], $c['week_id'], $c['days']); if ($result['action'] === 'NOOP') { $noop++; continue; } $applied++; $touchedWorkers[$c['sw_id']] = true; $this->audit->recordForRequest( action: $result['action'], entityType: 'sprint_worker_days', entityId: $result['after']?->id ?? $result['before']?->id, before: $result['before']?->toAuditSnapshot(), after: $result['after']?->toAuditSnapshot(), req: $req, actor: $actor, ); } $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not save cells', 500); } // Recompute capacity for every worker whose row changed. $grid = $this->days->grid($sprintId); $perWorker = []; foreach (array_keys($touchedWorkers) as $swId) { $ressourcen = array_sum($grid[$swId] ?? []); $perWorker[(string) $swId] = CapacityCalculator::forWorker( $ressourcen, $sprint->reserveFraction, 0.0, ); } return Response::ok([ 'applied' => $applied, 'noop' => $noop, 'per_worker' => $perWorker === [] ? new \stdClass() : $perWorker, ]); } /** PATCH /sprints/{id}/week/{week_id} — JSON — edit max_working_days for one week. */ public function updateWeekMax(Request $req, array $params): Response { $gate = $this->gateJsonAdmin($req); if ($gate instanceof Response) { return $gate; } $actor = $gate; $sprintId = (int) $params['id']; $weekId = (int) $params['week_id']; $week = $this->weeks->find($weekId); if ($week === null || $week->sprintId !== $sprintId) { return Response::err('not_found', 'sprint_week not found in this sprint', 404); } $body = $req->json() ?? []; if (!isset($body['max_working_days']) || !is_numeric($body['max_working_days'])) { return Response::err('validation', 'max_working_days required', 422); } $maxDays = (float) $body['max_working_days']; if (!CapacityCalculator::isHalfStep($maxDays, 0.0, 5.0)) { return Response::err('validation', 'max_working_days must be 0..5 in 0.5 steps', 422); } if (abs($week->maxWorkingDays - $maxDays) < 1e-9) { return Response::ok(['sprint_week' => $week->toAuditSnapshot()]); } $this->pdo->beginTransaction(); try { $result = $this->weeks->setMaxWorkingDays($weekId, $maxDays); $this->audit->recordForRequest( 'UPDATE', 'sprint_week', $weekId, $result['before']->toAuditSnapshot(), $result['after']->toAuditSnapshot(), $req, $actor, ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); return Response::err('db_error', 'Could not update week', 500); } return Response::ok(['sprint_week' => $result['after']->toAuditSnapshot()]); } // ------------------------------------------------------------------ // Shared helpers // ------------------------------------------------------------------ /** * Admin gate for JSON endpoints. Returns the signed-in User on success, * or an `Response::err(...)` JSON envelope on failure. Also enforces CSRF. */ private function gateJsonAdmin(Request $req): User|Response { $user = SessionGuard::currentUser($this->users); if ($user === null) { return Response::err('unauthenticated', 'Sign in required', 401); } if (!$user->isAdmin) { return Response::err('forbidden', 'Admin access required', 403); } if (!SessionGuard::verifyCsrf($req)) { return Response::err('csrf', 'CSRF token invalid', 403); } return $user; } private static function isIsoDate(string $s): bool { $d = DateTimeImmutable::createFromFormat('Y-m-d', $s); return $d !== false && $d->format('Y-m-d') === $s; } }