ConsumersController.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Domain\Audit\AuditAction;
  5. use App\Domain\Audit\AuditEmitter;
  6. use App\Infrastructure\Consumer\ConsumerRepository;
  7. use Psr\Http\Message\ResponseInterface;
  8. use Psr\Http\Message\ServerRequestInterface;
  9. /**
  10. * Admin CRUD over consumers. Mirrors ReportersController's shape; the
  11. * differences are `policy_id` (FK validated against `policies`) and the
  12. * delete-on-active-tokens consideration: the api_tokens FK is CASCADE,
  13. * so dropping a consumer revokes its tokens automatically. We still do
  14. * a soft delete by default to match reporters.
  15. */
  16. final class ConsumersController
  17. {
  18. use AdminControllerSupport;
  19. public function __construct(
  20. private readonly ConsumerRepository $consumers,
  21. private readonly AuditEmitter $audit,
  22. ) {
  23. }
  24. public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  25. {
  26. $page = self::pagination($request);
  27. $rows = $this->consumers->list($page['limit'], $page['offset']);
  28. $total = $this->consumers->count();
  29. return self::json($response, 200, [
  30. 'data' => array_map(static fn ($c) => $c->toArray(), $rows),
  31. 'page' => $page['page'],
  32. 'limit' => $page['limit'],
  33. 'total' => $total,
  34. ]);
  35. }
  36. /**
  37. * @param array{id: string} $args
  38. */
  39. public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  40. {
  41. $id = self::parseId($args['id']);
  42. if ($id === null) {
  43. return self::error($response, 404, 'not_found');
  44. }
  45. $consumer = $this->consumers->findById($id);
  46. if ($consumer === null) {
  47. return self::error($response, 404, 'not_found');
  48. }
  49. return self::json($response, 200, $consumer->toArray());
  50. }
  51. public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  52. {
  53. $body = self::jsonBody($request);
  54. $errors = [];
  55. $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
  56. if ($name === '' || strlen($name) > 128) {
  57. $errors['name'] = 'required, 1–128 chars';
  58. }
  59. $description = null;
  60. if (array_key_exists('description', $body)) {
  61. if ($body['description'] !== null && !is_string($body['description'])) {
  62. $errors['description'] = 'must be string or null';
  63. } else {
  64. $description = $body['description'];
  65. }
  66. }
  67. $policyId = null;
  68. if (!array_key_exists('policy_id', $body) || !is_int($body['policy_id'])) {
  69. // Accept numeric string too — JSON usually has int but be lenient.
  70. if (isset($body['policy_id']) && is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
  71. $policyId = (int) $body['policy_id'];
  72. } else {
  73. $errors['policy_id'] = 'required, integer';
  74. }
  75. } else {
  76. $policyId = $body['policy_id'];
  77. }
  78. if ($policyId !== null && $policyId > 0 && !$this->consumers->policyExists($policyId)) {
  79. $errors['policy_id'] = 'unknown policy';
  80. }
  81. if ($errors === [] && $this->consumers->findByName($name) !== null) {
  82. $errors['name'] = 'already exists';
  83. }
  84. if ($errors !== []) {
  85. return self::validationFailed($response, $errors);
  86. }
  87. /** @var int $policyId */
  88. $id = $this->consumers->create($name, $description, $policyId, self::actingUserId($request));
  89. $created = $this->consumers->findById($id);
  90. if ($created === null) {
  91. return self::error($response, 500, 'create_failed');
  92. }
  93. $this->audit->emit(
  94. AuditAction::CONSUMER_CREATED,
  95. 'consumer',
  96. $id,
  97. ['name' => $name, 'policy_id' => $policyId, 'description' => $description],
  98. self::auditContext($request),
  99. $name,
  100. );
  101. return self::json($response, 201, $created->toArray());
  102. }
  103. /**
  104. * @param array{id: string} $args
  105. */
  106. public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  107. {
  108. $id = self::parseId($args['id']);
  109. if ($id === null) {
  110. return self::error($response, 404, 'not_found');
  111. }
  112. $existing = $this->consumers->findById($id);
  113. if ($existing === null) {
  114. return self::error($response, 404, 'not_found');
  115. }
  116. $body = self::jsonBody($request);
  117. $errors = [];
  118. $fields = [];
  119. if (array_key_exists('name', $body)) {
  120. if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
  121. $errors['name'] = 'required, 1–128 chars';
  122. } else {
  123. $name = trim($body['name']);
  124. $other = $this->consumers->findByName($name);
  125. if ($other !== null && $other->id !== $id) {
  126. $errors['name'] = 'already exists';
  127. } else {
  128. $fields['name'] = $name;
  129. }
  130. }
  131. }
  132. if (array_key_exists('description', $body)) {
  133. if ($body['description'] !== null && !is_string($body['description'])) {
  134. $errors['description'] = 'must be string or null';
  135. } else {
  136. $fields['description'] = $body['description'];
  137. }
  138. }
  139. if (array_key_exists('policy_id', $body)) {
  140. $newPolicy = null;
  141. if (is_int($body['policy_id'])) {
  142. $newPolicy = $body['policy_id'];
  143. } elseif (is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
  144. $newPolicy = (int) $body['policy_id'];
  145. }
  146. if ($newPolicy === null || $newPolicy <= 0) {
  147. $errors['policy_id'] = 'must be positive integer';
  148. } elseif (!$this->consumers->policyExists($newPolicy)) {
  149. $errors['policy_id'] = 'unknown policy';
  150. } else {
  151. $fields['policy_id'] = $newPolicy;
  152. }
  153. }
  154. if (array_key_exists('is_active', $body)) {
  155. if (!is_bool($body['is_active'])) {
  156. $errors['is_active'] = 'must be boolean';
  157. } else {
  158. $fields['is_active'] = $body['is_active'] ? 1 : 0;
  159. }
  160. }
  161. if (array_key_exists('audit_enabled', $body)) {
  162. if (!is_bool($body['audit_enabled'])) {
  163. $errors['audit_enabled'] = 'must be boolean';
  164. } else {
  165. $fields['audit_enabled'] = $body['audit_enabled'] ? 1 : 0;
  166. }
  167. }
  168. if ($errors !== []) {
  169. return self::validationFailed($response, $errors);
  170. }
  171. $beforeSnapshot = [
  172. 'name' => $existing->name,
  173. 'description' => $existing->description,
  174. 'policy_id' => $existing->policyId,
  175. 'is_active' => $existing->isActive ? 1 : 0,
  176. 'audit_enabled' => $existing->auditEnabled ? 1 : 0,
  177. ];
  178. $this->consumers->update($id, $fields);
  179. $updated = $this->consumers->findById($id);
  180. if ($updated === null) {
  181. return self::error($response, 500, 'update_failed');
  182. }
  183. $this->audit->emit(
  184. AuditAction::CONSUMER_UPDATED,
  185. 'consumer',
  186. $id,
  187. ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
  188. self::auditContext($request),
  189. $updated->name,
  190. );
  191. return self::json($response, 200, $updated->toArray());
  192. }
  193. /**
  194. * @param array{id: string} $args
  195. */
  196. public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  197. {
  198. $id = self::parseId($args['id']);
  199. if ($id === null) {
  200. return self::error($response, 404, 'not_found');
  201. }
  202. $existing = $this->consumers->findById($id);
  203. if ($existing === null) {
  204. return self::error($response, 404, 'not_found');
  205. }
  206. $this->consumers->softDelete($id);
  207. $this->audit->emit(
  208. AuditAction::CONSUMER_DELETED,
  209. 'consumer',
  210. $id,
  211. ['name' => $existing->name, 'soft' => true],
  212. self::auditContext($request),
  213. $existing->name,
  214. );
  215. return $response->withStatus(204);
  216. }
  217. private static function parseId(string $raw): ?int
  218. {
  219. return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
  220. }
  221. }