200 lines
9.3 KiB
PHP
200 lines
9.3 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Notification;
|
|
|
|
/** Normalizes and validates notification metadata; delivery is deliberately out of scope. */
|
|
final class NotificationRecord
|
|
{
|
|
/** @var list<string> */
|
|
private const TYPES = [
|
|
'jobcard_created', 'jobcard_status_changed', 'assignment_created',
|
|
'time_entry_created', 'sla_threshold', 'attachment_uploaded',
|
|
];
|
|
|
|
/** @return array<string, mixed> */
|
|
public function normalize(array $record): array
|
|
{
|
|
$type = is_scalar($record['type'] ?? null) ? strtolower(trim((string) $record['type'])) : '';
|
|
$rawRecipients = $record['recipients'] ?? ($record['recipient'] ?? []);
|
|
if (!is_array($rawRecipients)) $rawRecipients = [$rawRecipients];
|
|
$recipients = [];
|
|
foreach ($rawRecipients as $recipient) {
|
|
if (is_scalar($recipient)) {
|
|
$value = strtolower(trim((string) $recipient));
|
|
if ($value !== '' && !in_array($value, $recipients, true)) $recipients[] = $value;
|
|
}
|
|
}
|
|
$key = $record['deduplication_key'] ?? $record['dedup_key'] ?? null;
|
|
$key = is_scalar($key) ? strtolower(trim((string) $key)) : '';
|
|
return [
|
|
'type' => $type,
|
|
'recipients' => $recipients,
|
|
'is_read' => $this->normalizeReadState($record),
|
|
'deduplication_key' => $key,
|
|
];
|
|
}
|
|
|
|
/** Validate the command used by a user to change one notification's read state. */
|
|
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];
|
|
}
|
|
|
|
/** Alias with an explicit command name for callers that mark notifications unread. */
|
|
public function validateMarkUnread(array $command): array
|
|
{
|
|
return $this->validateMarkRead($command);
|
|
}
|
|
|
|
/** 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
|
|
{
|
|
$normalized = $this->normalize($record);
|
|
$errors = [];
|
|
if (!in_array($normalized['type'], self::TYPES, true)) {
|
|
$errors['type'] = 'Notification type is not supported.';
|
|
}
|
|
if ($normalized['recipients'] === []) {
|
|
$errors['recipients'] = 'At least one notification recipient is required.';
|
|
} else {
|
|
foreach ($normalized['recipients'] as $recipient) {
|
|
if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
|
|
$errors['recipients'] = 'Notification recipients must be valid email addresses.';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!is_bool($normalized['is_read'])) $errors['is_read'] = 'Read state must be boolean.';
|
|
if ($normalized['deduplication_key'] === '' || mb_strlen($normalized['deduplication_key']) > 190 || preg_match('/[\x00-\x1F\x7F]/', $normalized['deduplication_key']) === 1) {
|
|
$errors['deduplication_key'] = 'A safe deduplication key is required and must be 190 characters or fewer.';
|
|
}
|
|
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
|
|
}
|
|
|
|
public function deduplicationKey(array $record): string
|
|
{
|
|
return $this->normalize($record)['deduplication_key'];
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function display(array $record): array
|
|
{
|
|
$normalized = $this->normalize($record);
|
|
return [
|
|
'type' => $normalized['type'],
|
|
'recipients' => $normalized['recipients'],
|
|
'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 array<string, mixed> */
|
|
public function toDisplay(array $record): array { return $this->display($record); }
|
|
|
|
private function normalizeBoolean(mixed $value): mixed
|
|
{
|
|
if (is_bool($value)) return $value;
|
|
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
|
|
if (is_string($value)) return match (strtolower(trim($value))) {
|
|
'1', 'true', 'yes', 'on' => true,
|
|
'0', 'false', 'no', 'off' => false,
|
|
default => $value,
|
|
};
|
|
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];
|
|
}
|
|
}
|