|
@@ -5,24 +5,32 @@ declare(strict_types=1);
|
|
|
namespace App\Controllers;
|
|
namespace App\Controllers;
|
|
|
|
|
|
|
|
use App\Auth\SessionGuard;
|
|
use App\Auth\SessionGuard;
|
|
|
|
|
+use App\Domain\User;
|
|
|
use App\Http\Request;
|
|
use App\Http\Request;
|
|
|
use App\Http\Response;
|
|
use App\Http\Response;
|
|
|
use App\Http\View;
|
|
use App\Http\View;
|
|
|
use App\Repositories\SprintRepository;
|
|
use App\Repositories\SprintRepository;
|
|
|
|
|
+use App\Repositories\SprintWeekRepository;
|
|
|
|
|
+use App\Repositories\SprintWorkerRepository;
|
|
|
use App\Repositories\UserRepository;
|
|
use App\Repositories\UserRepository;
|
|
|
|
|
+use App\Repositories\WorkerRepository;
|
|
|
use App\Services\AuditLogger;
|
|
use App\Services\AuditLogger;
|
|
|
use DateTimeImmutable;
|
|
use DateTimeImmutable;
|
|
|
use PDO;
|
|
use PDO;
|
|
|
|
|
+use PDOException;
|
|
|
use Throwable;
|
|
use Throwable;
|
|
|
|
|
|
|
|
final class SprintController
|
|
final class SprintController
|
|
|
{
|
|
{
|
|
|
public function __construct(
|
|
public function __construct(
|
|
|
- private readonly PDO $pdo,
|
|
|
|
|
- private readonly UserRepository $users,
|
|
|
|
|
- private readonly SprintRepository $sprints,
|
|
|
|
|
- private readonly AuditLogger $audit,
|
|
|
|
|
- private readonly View $view,
|
|
|
|
|
|
|
+ private readonly PDO $pdo,
|
|
|
|
|
+ private readonly UserRepository $users,
|
|
|
|
|
+ private readonly SprintRepository $sprints,
|
|
|
|
|
+ private readonly SprintWeekRepository $weeks,
|
|
|
|
|
+ private readonly SprintWorkerRepository $sprintWorkers,
|
|
|
|
|
+ private readonly WorkerRepository $workers,
|
|
|
|
|
+ private readonly AuditLogger $audit,
|
|
|
|
|
+ private readonly View $view,
|
|
|
) {
|
|
) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -159,4 +167,393 @@ final class SprintController
|
|
|
'sprint' => $sprint,
|
|
'sprint' => $sprint,
|
|
|
]));
|
|
]));
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+ // 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()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 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;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|