$c->toArray(), $this->categories->listAll()); return self::json($response, 200, [ 'items' => $items, 'total' => count($items), ]); } /** * @param array{id: string} $args */ public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface { $id = self::parseId($args['id']); if ($id === null) { return self::error($response, 404, 'not_found'); } $category = $this->categories->findById($id); if ($category === null) { return self::error($response, 404, 'not_found'); } return self::json($response, 200, $category->toArray()); } public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $body = self::jsonBody($request); $errors = []; $slug = isset($body['slug']) && is_string($body['slug']) ? trim($body['slug']) : ''; if ($slug === '' || preg_match(self::SLUG_PATTERN, $slug) !== 1) { $errors['slug'] = 'required, lowercase alpha + digits + underscore, ≤64 chars'; } elseif ($this->categories->findBySlug($slug) !== null) { $errors['slug'] = 'already exists'; } $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : ''; if ($name === '' || strlen($name) > 128) { $errors['name'] = 'required, 1–128 chars'; } $description = null; if (array_key_exists('description', $body)) { if ($body['description'] !== null && !is_string($body['description'])) { $errors['description'] = 'must be string or null'; } else { $description = $body['description']; } } $decayFunction = null; $rawFn = $body['decay_function'] ?? null; if (is_string($rawFn)) { $decayFunction = DecayFunction::tryFrom($rawFn); } if ($decayFunction === null) { $errors['decay_function'] = 'required, "linear" or "exponential"'; } $decayParam = null; if (isset($body['decay_param']) && (is_int($body['decay_param']) || is_float($body['decay_param']))) { $decayParam = (float) $body['decay_param']; if ($decayParam <= 0) { $errors['decay_param'] = 'must be positive'; } } else { $errors['decay_param'] = 'required, positive number'; } $isActive = true; if (array_key_exists('is_active', $body)) { if (!is_bool($body['is_active'])) { $errors['is_active'] = 'must be boolean'; } else { $isActive = $body['is_active']; } } if ($errors !== []) { return self::validationFailed($response, $errors); } /** @var DecayFunction $decayFunction */ /** @var float $decayParam */ $id = $this->categories->create($slug, $name, $description, $decayFunction, $decayParam, $isActive); $created = $this->categories->findById($id); if ($created === null) { return self::error($response, 500, 'create_failed'); } $this->audit->emit( AuditAction::CATEGORY_CREATED, 'category', $id, ['slug' => $slug, 'name' => $name, 'decay_function' => $decayFunction->value, 'decay_param' => $decayParam], self::auditContext($request), $slug, ); return self::json($response, 201, $created->toArray()); } /** * @param array{id: string} $args */ public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface { $id = self::parseId($args['id']); if ($id === null) { return self::error($response, 404, 'not_found'); } $existing = $this->categories->findById($id); if ($existing === null) { return self::error($response, 404, 'not_found'); } $body = self::jsonBody($request); $errors = []; $fields = []; if (array_key_exists('slug', $body)) { if (!is_string($body['slug']) || preg_match(self::SLUG_PATTERN, trim($body['slug'])) !== 1) { $errors['slug'] = 'lowercase alpha + digits + underscore, ≤64 chars'; } else { $newSlug = trim($body['slug']); $other = $this->categories->findBySlug($newSlug); if ($other !== null && $other->id !== $id) { $errors['slug'] = 'already exists'; } else { $fields['slug'] = $newSlug; } } } if (array_key_exists('name', $body)) { if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) { $errors['name'] = 'required, 1–128 chars'; } else { $fields['name'] = trim($body['name']); } } if (array_key_exists('description', $body)) { if ($body['description'] !== null && !is_string($body['description'])) { $errors['description'] = 'must be string or null'; } else { $fields['description'] = $body['description']; } } if (array_key_exists('decay_function', $body)) { $fn = is_string($body['decay_function']) ? DecayFunction::tryFrom($body['decay_function']) : null; if ($fn === null) { $errors['decay_function'] = '"linear" or "exponential"'; } else { $fields['decay_function'] = $fn->value; } } if (array_key_exists('decay_param', $body)) { if (is_int($body['decay_param']) || is_float($body['decay_param'])) { $value = (float) $body['decay_param']; if ($value <= 0) { $errors['decay_param'] = 'must be positive'; } else { $fields['decay_param'] = number_format($value, 4, '.', ''); } } else { $errors['decay_param'] = 'must be positive number'; } } if (array_key_exists('is_active', $body)) { if (!is_bool($body['is_active'])) { $errors['is_active'] = 'must be boolean'; } else { $fields['is_active'] = $body['is_active'] ? 1 : 0; } } if ($errors !== []) { return self::validationFailed($response, $errors); } $beforeSnapshot = [ 'slug' => $existing->slug, 'name' => $existing->name, 'description' => $existing->description, 'decay_function' => $existing->decayFunction->value, 'decay_param' => number_format($existing->decayParam, 4, '.', ''), 'is_active' => $existing->isActive ? 1 : 0, ]; $this->categories->update($id, $fields); $updated = $this->categories->findById($id); if ($updated === null) { return self::error($response, 500, 'update_failed'); } $this->audit->emit( AuditAction::CATEGORY_UPDATED, 'category', $id, ['slug' => $existing->slug, 'changes' => self::diffFields($beforeSnapshot, $fields)], self::auditContext($request), $updated->slug, ); return self::json($response, 200, $updated->toArray()); } /** * @param array{id: string} $args */ public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface { $id = self::parseId($args['id']); if ($id === null) { return self::error($response, 404, 'not_found'); } $existing = $this->categories->findById($id); if ($existing === null) { return self::error($response, 404, 'not_found'); } $policyRefs = $this->categories->policyReferenceCount($id); $reportRefs = $this->categories->reportReferenceCount($id); if ($policyRefs > 0 || $reportRefs > 0) { return self::json($response, 409, [ 'error' => 'category_in_use', 'usage' => [ 'policies' => $policyRefs, 'reports' => $reportRefs, ], 'hint' => 'PATCH with is_active=false to soft-delete instead.', ]); } $this->categories->delete($id); $this->audit->emit( AuditAction::CATEGORY_DELETED, 'category', $id, ['slug' => $existing->slug, 'name' => $existing->name], self::auditContext($request), $existing->slug, ); return $response->withStatus(204); } private static function parseId(string $raw): ?int { return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null; } }