WorkerController.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Controllers;
  12. use App\Auth\SessionGuard;
  13. use App\Http\Request;
  14. use App\Http\Response;
  15. use App\Http\View;
  16. use App\Repositories\UserRepository;
  17. use App\Repositories\WorkerRepository;
  18. use App\Services\AuditLogger;
  19. use PDO;
  20. use PDOException;
  21. use Throwable;
  22. /**
  23. * /workers — master data for the people to whom sprint tasks are assigned.
  24. *
  25. * Access: admin only (all methods). Workers are independent from users: a
  26. * worker is not required to ever log in.
  27. */
  28. final class WorkerController
  29. {
  30. public function __construct(
  31. private readonly PDO $pdo,
  32. private readonly UserRepository $users,
  33. private readonly WorkerRepository $workers,
  34. private readonly AuditLogger $audit,
  35. private readonly View $view,
  36. ) {
  37. }
  38. /** GET /workers */
  39. public function index(Request $req): Response
  40. {
  41. $actor = SessionGuard::requireAdmin($this->users);
  42. if ($actor instanceof Response) {
  43. return $actor;
  44. }
  45. return Response::html($this->view->render('workers/index', [
  46. 'title' => 'Workers',
  47. 'currentUser' => $actor,
  48. 'csrfToken' => SessionGuard::csrfToken(),
  49. 'workers' => $this->workers->all(),
  50. 'flash' => $req->queryString('flash'),
  51. 'error' => $req->queryString('error'),
  52. ]));
  53. }
  54. /** POST /workers — create a worker. */
  55. public function create(Request $req): Response
  56. {
  57. $actor = SessionGuard::requireAdmin($this->users);
  58. if ($actor instanceof Response) {
  59. return $actor;
  60. }
  61. if (!SessionGuard::verifyCsrf($req)) {
  62. return Response::text('CSRF token invalid', 403);
  63. }
  64. $name = trim($req->postString('name'));
  65. $rtbRaw = $req->postString('default_rtb');
  66. $rtb = $rtbRaw === '' ? 0.0 : (float) $rtbRaw;
  67. if ($name === '') {
  68. return Response::redirect('/workers?error=name_required');
  69. }
  70. if ($rtb < 0.0 || $rtb > 1.0) {
  71. return Response::redirect('/workers?error=rtb_out_of_range');
  72. }
  73. $this->pdo->beginTransaction();
  74. try {
  75. $worker = $this->workers->create($name, true, $rtb);
  76. $this->audit->recordForRequest(
  77. action: 'CREATE',
  78. entityType: 'worker',
  79. entityId: $worker->id,
  80. before: null,
  81. after: $worker->toAuditSnapshot(),
  82. req: $req,
  83. actor: $actor,
  84. );
  85. $this->pdo->commit();
  86. } catch (PDOException $e) {
  87. $this->pdo->rollBack();
  88. // UNIQUE(name) violation or similar.
  89. return Response::redirect('/workers?error=' . urlencode(
  90. str_contains(strtolower($e->getMessage()), 'unique') ? 'name_taken' : 'db_error'
  91. ));
  92. } catch (Throwable) {
  93. $this->pdo->rollBack();
  94. return Response::redirect('/workers?error=db_error');
  95. }
  96. return Response::redirect('/workers?flash=created');
  97. }
  98. /** POST /workers/{id} (form) — update a worker's editable fields. */
  99. public function update(Request $req, array $params): Response
  100. {
  101. $actor = SessionGuard::requireAdmin($this->users);
  102. if ($actor instanceof Response) {
  103. return $actor;
  104. }
  105. if (!SessionGuard::verifyCsrf($req)) {
  106. return Response::text('CSRF token invalid', 403);
  107. }
  108. $id = (int) $params['id'];
  109. $existing = $this->workers->find($id);
  110. if ($existing === null) {
  111. return Response::text('Not Found', 404);
  112. }
  113. $changes = [];
  114. $name = trim($req->postString('name'));
  115. if ($name !== '' && $name !== $existing->name) {
  116. $changes['name'] = $name;
  117. }
  118. $rtbRaw = $req->postString('default_rtb');
  119. if ($rtbRaw !== '') {
  120. $rtb = (float) $rtbRaw;
  121. if ($rtb < 0.0 || $rtb > 1.0) {
  122. return Response::redirect('/workers?error=rtb_out_of_range');
  123. }
  124. if (abs($rtb - $existing->defaultRtb) > 1e-9) {
  125. $changes['default_rtb'] = $rtb;
  126. }
  127. }
  128. // is_active comes via checkbox — absent means "off".
  129. $isActive = isset($req->post['is_active'])
  130. && (string) $req->post['is_active'] !== '0';
  131. if ($isActive !== $existing->isActive) {
  132. $changes['is_active'] = $isActive;
  133. }
  134. if ($changes === []) {
  135. return Response::redirect('/workers?flash=noop');
  136. }
  137. $this->pdo->beginTransaction();
  138. try {
  139. $result = $this->workers->update($id, $changes);
  140. $this->audit->recordForRequest(
  141. action: 'UPDATE',
  142. entityType: 'worker',
  143. entityId: $id,
  144. before: $result['before']->toAuditSnapshot(),
  145. after: $result['after']->toAuditSnapshot(),
  146. req: $req,
  147. actor: $actor,
  148. );
  149. $this->pdo->commit();
  150. } catch (PDOException $e) {
  151. $this->pdo->rollBack();
  152. return Response::redirect('/workers?error=' . urlencode(
  153. str_contains(strtolower($e->getMessage()), 'unique') ? 'name_taken' : 'db_error'
  154. ));
  155. } catch (Throwable) {
  156. $this->pdo->rollBack();
  157. return Response::redirect('/workers?error=db_error');
  158. }
  159. return Response::redirect('/workers?flash=updated');
  160. }
  161. }