|
|
@@ -0,0 +1,173 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+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');
|
|
|
+ }
|
|
|
+}
|