feat: complete jobcard client management foundation
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Notification;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Persists validated notifications as per-user rows with database-backed deduplication. */
|
||||
final class NotificationQueue
|
||||
{
|
||||
public function __construct(private readonly ?NotificationRecord $records = null)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<int> inserted notification ids */
|
||||
public function enqueue(PDO $pdo, array $record): array
|
||||
{
|
||||
$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();
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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->normalizeBoolean($record['is_read'] ?? $record['read'] ?? false),
|
||||
'deduplication_key' => $key,
|
||||
];
|
||||
}
|
||||
|
||||
/** @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'],
|
||||
'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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user