2026-09-01 20:43:24 +02:00
<? 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 ,
2026-09-01 21:03:38 +02:00
'is_read' => $this -> normalizeReadState ( $record ),
2026-09-01 20:43:24 +02:00
'deduplication_key' => $key ,
];
}
2026-09-01 21:47:35 +02:00
/** Validate the command used by a user to change one notification's read state. */
2026-09-01 21:03:38 +02:00
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 ];
}
2026-09-01 21:47:35 +02:00
/** Alias with an explicit command name for callers that mark notifications unread. */
public function validateMarkUnread ( array $command ) : array
{
return $this -> validateMarkRead ( $command );
}
2026-09-01 21:03:38 +02:00
/** 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 );
}
2026-09-01 20:43:24 +02:00
/** @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' ],
2026-09-01 21:47:35 +02:00
'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 ,
2026-09-01 20:43:24 +02:00
'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 ;
}
2026-09-01 21:03:38 +02:00
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 ];
}
2026-09-01 20:43:24 +02:00
}