* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Controllers; use App\Auth\SessionGuard; use App\Http\Request; use App\Http\Response; use App\Http\View; use App\Repositories\UserRepository; use App\Repositories\WorkerRepository; use App\Services\AuditLogger; use PDO; use PDOException; use Throwable; /** * /workers — master data for the people to whom sprint tasks are assigned. * * Access: admin only (all methods). Workers are independent from users: a * worker is not required to ever log in. */ final class WorkerController { public function __construct( private readonly PDO $pdo, private readonly UserRepository $users, private readonly WorkerRepository $workers, private readonly AuditLogger $audit, private readonly View $view, ) { } /** GET /workers */ public function index(Request $req): Response { $actor = SessionGuard::requireAdmin($this->users); if ($actor instanceof Response) { return $actor; } return Response::html($this->view->render('workers/index', [ 'title' => 'Workers', 'currentUser' => $actor, 'csrfToken' => SessionGuard::csrfToken(), 'workers' => $this->workers->all(), 'flash' => $req->queryString('flash'), 'error' => $req->queryString('error'), ])); } /** POST /workers — create a worker. */ 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')); $rtbRaw = $req->postString('default_rtb'); $rtb = $rtbRaw === '' ? 0.0 : (float) $rtbRaw; if ($name === '') { return Response::redirect('/workers?error=name_required'); } if ($rtb < 0.0 || $rtb > 1.0) { return Response::redirect('/workers?error=rtb_out_of_range'); } $this->pdo->beginTransaction(); try { $worker = $this->workers->create($name, true, $rtb); $this->audit->recordForRequest( action: 'CREATE', entityType: 'worker', entityId: $worker->id, before: null, after: $worker->toAuditSnapshot(), req: $req, actor: $actor, ); $this->pdo->commit(); } catch (PDOException $e) { $this->pdo->rollBack(); // UNIQUE(name) violation or similar. return Response::redirect('/workers?error=' . urlencode( str_contains(strtolower($e->getMessage()), 'unique') ? 'name_taken' : 'db_error' )); } catch (Throwable) { $this->pdo->rollBack(); return Response::redirect('/workers?error=db_error'); } return Response::redirect('/workers?flash=created'); } /** POST /workers/{id} (form) — update a worker's editable fields. */ public function update(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); } $id = (int) $params['id']; $existing = $this->workers->find($id); if ($existing === null) { return Response::text('Not Found', 404); } $changes = []; $name = trim($req->postString('name')); if ($name !== '' && $name !== $existing->name) { $changes['name'] = $name; } $rtbRaw = $req->postString('default_rtb'); if ($rtbRaw !== '') { $rtb = (float) $rtbRaw; if ($rtb < 0.0 || $rtb > 1.0) { return Response::redirect('/workers?error=rtb_out_of_range'); } if (abs($rtb - $existing->defaultRtb) > 1e-9) { $changes['default_rtb'] = $rtb; } } // is_active comes via checkbox — absent means "off". $isActive = isset($req->post['is_active']) && (string) $req->post['is_active'] !== '0'; if ($isActive !== $existing->isActive) { $changes['is_active'] = $isActive; } if ($changes === []) { return Response::redirect('/workers?flash=noop'); } $this->pdo->beginTransaction(); try { $result = $this->workers->update($id, $changes); $this->audit->recordForRequest( action: 'UPDATE', entityType: 'worker', entityId: $id, before: $result['before']->toAuditSnapshot(), after: $result['after']->toAuditSnapshot(), req: $req, actor: $actor, ); $this->pdo->commit(); } catch (PDOException $e) { $this->pdo->rollBack(); return Response::redirect('/workers?error=' . urlencode( str_contains(strtolower($e->getMessage()), 'unique') ? 'name_taken' : 'db_error' )); } catch (Throwable) { $this->pdo->rollBack(); return Response::redirect('/workers?error=db_error'); } return Response::redirect('/workers?flash=updated'); } }