feat: add administration reporting and correction services
This commit is contained in:
@@ -19,18 +19,43 @@ final class NotificationQueue
|
||||
$validation = ($this->records ?? new NotificationRecord())->validate($record);
|
||||
if (!$validation['valid']) throw new InvalidArgumentException('Invalid notification: ' . implode(' ', $validation['errors']));
|
||||
$title = is_scalar($record['title'] ?? null) ? trim((string)$record['title']) : '';
|
||||
$body = is_scalar($record['body'] ?? null) ? trim((string)$record['body']) : '';
|
||||
if ($title === '' || mb_strlen($title) > 190) throw new InvalidArgumentException('Notification title is required and must be 190 characters or fewer.');
|
||||
$lookup = $pdo->prepare('SELECT id FROM users WHERE email = :email AND is_active = 1 LIMIT 1');
|
||||
$insert = $pdo->prepare('INSERT INTO notifications (user_id, type, title, body, deduplication_key, read_at) VALUES (:user, :type, :title, :body, :dedup, :read_at) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)');
|
||||
$ids = [];
|
||||
foreach ($validation['recipients'] as $email) {
|
||||
$lookup->execute(['email' => $email]);
|
||||
$userId = $lookup->fetchColumn();
|
||||
if ($userId === false) continue;
|
||||
$insert->execute(['user' => $userId, 'type' => $validation['type'], 'title' => $title, 'body' => $body === '' ? null : $body, 'dedup' => $validation['deduplication_key'], 'read_at' => $validation['is_read'] ? date('Y-m-d H:i:s') : null]);
|
||||
$ids[] = (int)$pdo->lastInsertId();
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
foreach ($validation['recipients'] as $email) {
|
||||
$dto = ($this->records ?? new NotificationRecord())->toQueueDto($record, $email);
|
||||
$lookup->execute(['email' => $email]);
|
||||
$userId = $lookup->fetchColumn();
|
||||
if ($userId === false) continue;
|
||||
$insert->execute(['user' => $userId, 'type' => $dto['type'], 'title' => $dto['title'], 'body' => $dto['body'], 'dedup' => $dto['deduplication_key'], 'read_at' => $dto['is_read'] ? date('Y-m-d H:i:s') : null]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
if ($id > 0 && !in_array($id, $ids, true)) $ids[] = $id;
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $exception) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** Map a multi-recipient notification into one queue DTO for one user. */
|
||||
public function mapForUser(array $record, string $recipient): array
|
||||
{
|
||||
return ($this->records ?? new NotificationRecord())->toQueueDto($record, $recipient);
|
||||
}
|
||||
|
||||
/** Mark a notification read only for the authenticated user's row. */
|
||||
public function markRead(PDO $pdo, int|string $userId, array $command): bool
|
||||
{
|
||||
$user = filter_var($userId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
$validation = ($this->records ?? new NotificationRecord())->validateMarkRead($command);
|
||||
if ($user === false || !$validation['valid']) throw new InvalidArgumentException('Invalid mark-read command.');
|
||||
$stmt = $pdo->prepare('UPDATE notifications SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = :id AND user_id = :user');
|
||||
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,75 @@ final class NotificationRecord
|
||||
return [
|
||||
'type' => $type,
|
||||
'recipients' => $recipients,
|
||||
'is_read' => $this->normalizeBoolean($record['is_read'] ?? $record['read'] ?? false),
|
||||
'is_read' => $this->normalizeReadState($record),
|
||||
'deduplication_key' => $key,
|
||||
];
|
||||
}
|
||||
|
||||
/** Validate the command used by a user to mark one notification read. */
|
||||
public function validateMarkRead(array $command): array
|
||||
{
|
||||
$value = $command['notification_id'] ?? $command['id'] ?? null;
|
||||
$notificationId = $this->positiveInteger($value);
|
||||
$errors = $notificationId === null
|
||||
? ['notification_id' => 'Notification ID must be a positive integer.']
|
||||
: [];
|
||||
return ['valid' => $errors === [], 'notification_id' => $notificationId, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Return one normalized queue DTO for a single recipient. */
|
||||
public function toQueueDto(array $record, string $recipient): array
|
||||
{
|
||||
$normalized = $this->normalize($record);
|
||||
$recipient = strtolower(trim($recipient));
|
||||
if (!in_array($recipient, $normalized['recipients'], true)) {
|
||||
throw new \InvalidArgumentException('Recipient is not present in the notification.');
|
||||
}
|
||||
return [
|
||||
'type' => $normalized['type'],
|
||||
'recipient' => $recipient,
|
||||
'title' => is_scalar($record['title'] ?? null) ? trim((string) $record['title']) : '',
|
||||
'body' => is_scalar($record['body'] ?? null) && trim((string) $record['body']) !== '' ? trim((string) $record['body']) : null,
|
||||
'is_read' => $normalized['is_read'],
|
||||
'deduplication_key' => $normalized['deduplication_key'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> one DTO per normalized recipient */
|
||||
public function toQueueDtos(array $record): array
|
||||
{
|
||||
$normalized = $this->normalize($record);
|
||||
return array_map(fn (string $recipient): array => $this->toQueueDto($record, $recipient), $normalized['recipients']);
|
||||
}
|
||||
|
||||
/** Build the assignment notification payload before queueing it. */
|
||||
public function assignmentCreated(array $event): array
|
||||
{
|
||||
$jobcardId = $this->positiveInteger($event['jobcard_id'] ?? null);
|
||||
if ($jobcardId === null) throw new \InvalidArgumentException('Jobcard ID must be a positive integer.');
|
||||
$reference = $this->text($event['jobcard_reference'] ?? $jobcardId);
|
||||
$technician = $this->text($event['technician_name'] ?? null);
|
||||
return $this->eventPayload($event, 'assignment_created', 'Jobcard assigned', sprintf('Jobcard %s was assigned%s.', $reference, $technician ? ' to ' . $technician : ''), 'assignment:' . $jobcardId);
|
||||
}
|
||||
|
||||
/** Build the jobcard status notification payload before queueing it. */
|
||||
public function statusChanged(array $event): array
|
||||
{
|
||||
$jobcardId = $this->positiveInteger($event['jobcard_id'] ?? null);
|
||||
$to = strtolower($this->text($event['to_status'] ?? null));
|
||||
if ($jobcardId === null || $to === '') throw new \InvalidArgumentException('Jobcard ID and destination status are required.');
|
||||
return $this->eventPayload($event, 'jobcard_status_changed', 'Jobcard status changed', sprintf('Jobcard %s status changed to %s.', $event['jobcard_reference'] ?? $jobcardId, $to), 'jobcard:' . $jobcardId . ':status:' . $to);
|
||||
}
|
||||
|
||||
/** Build an SLA threshold notification payload before queueing it. */
|
||||
public function slaThreshold(array $event): array
|
||||
{
|
||||
$clientId = $this->positiveInteger($event['client_id'] ?? null);
|
||||
$threshold = strtolower($this->text($event['threshold'] ?? $event['level'] ?? null));
|
||||
if ($clientId === null || $threshold === '') throw new \InvalidArgumentException('Client ID and SLA threshold are required.');
|
||||
return $this->eventPayload($event, 'sla_threshold', 'SLA threshold reached', sprintf('Client %s has reached the %s SLA threshold.', $event['client_name'] ?? $clientId, $threshold), 'sla:' . $clientId . ':' . $threshold);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(array $record): array
|
||||
{
|
||||
@@ -91,4 +155,37 @@ final class NotificationRecord
|
||||
};
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function normalizeReadState(array $record): mixed
|
||||
{
|
||||
if (array_key_exists('read_at', $record)) {
|
||||
if ($record['read_at'] === null) return false;
|
||||
return is_scalar($record['read_at']) ? trim((string) $record['read_at']) !== '' : $record['read_at'];
|
||||
}
|
||||
$value = $record['is_read'] ?? $record['read'] ?? false;
|
||||
if (is_string($value) && strtolower(trim($value)) === 'read') return true;
|
||||
if (is_string($value) && strtolower(trim($value)) === 'unread') return false;
|
||||
return $this->normalizeBoolean($value);
|
||||
}
|
||||
|
||||
private function positiveInteger(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) return $value;
|
||||
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
|
||||
$trimmed = trim($value);
|
||||
$integer = filter_var($trimmed, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
return $integer === false || (string)$integer !== $trimmed ? null : $integer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
return is_scalar($value) ? trim((string) $value) : '';
|
||||
}
|
||||
|
||||
private function eventPayload(array $event, string $type, string $title, string $body, string $deduplicationKey): array
|
||||
{
|
||||
return [...$this->normalize([...$event, 'type' => $type, 'deduplication_key' => $deduplicationKey]), 'title' => $title, 'body' => $body];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user