update project

This commit is contained in:
root
2026-05-30 01:11:35 -04:00
parent 3a0628ecc7
commit 2225f6bc72
9743 changed files with 1122482 additions and 59 deletions
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Channels; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\EmailChannelContract; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult}; final class EmailChannel implements EmailChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult { return new DeliveryResult('queued','email_'.sha1((string)microtime(true)),null,['adapter'=>self::class]); } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Channels; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\NotificationChannelContract; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult}; final class NotificationChannel implements NotificationChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult { return new DeliveryResult('queued','notif_'.sha1((string)microtime(true)),null,['adapter'=>self::class]); } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Channels; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\SmsChannelContract; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult}; final class SmsChannel implements SmsChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult { return new DeliveryResult('queued','sms_'.sha1((string)microtime(true)),null,['adapter'=>self::class]); } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Channels; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\WhatsAppChannelContract; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult}; final class WhatsAppChannel implements WhatsAppChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult { return new DeliveryResult('queued','wa_'.sha1((string)microtime(true)),null,['adapter'=>self::class]); } }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult};
interface ChannelRouterContract { public function route(SchoolContext $context, ChannelMessageData $message): DeliveryResult; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\CommunicationAuditData;
interface CommunicationAuditLoggerContract { public function log(SchoolContext $context, CommunicationAuditData $data): void; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
interface CommunicationIdempotencyServiceContract { public function rememberOrFail(SchoolContext $context, ?string $key, string $operation, array $payload, callable $callback): mixed; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Enums\CommunicationChannel; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
interface CommunicationPreferencePolicyContract { public function filterAllowedRecipients(SchoolContext $context, RecipientSet $recipients, CommunicationChannel $channel, string $category): RecipientSet; public function canOverridePreferences(SchoolContext $context, string $category): bool; }
@@ -0,0 +1,5 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Communication\DTO\{SendMessageData,BulkSendMessageData,SendResult,BulkSendResult,ScheduledMessageResult};
interface CommunicationServiceContract { public function sendMessage(SchoolContext $context, SendMessageData $data): SendResult; public function sendBulkMessage(SchoolContext $context, BulkSendMessageData $data): BulkSendResult; public function scheduleBulkMessage(SchoolContext $context, BulkSendMessageData $data): ScheduledMessageResult; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\DeliveryReceiptData;
interface DeliveryStatusServiceContract { public function recordReceipt(SchoolContext $context, DeliveryReceiptData $receipt): array; public function markFailed(SchoolContext $context, int $messageRecipientId, string $reason): void; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult};
interface EmailChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\MessageTemplateData;
interface MessageTemplateServiceContract { public function create(SchoolContext $context, MessageTemplateData $data): array; public function render(SchoolContext $context, string $templateKey, array $variables = [], ?string $language = null): string; public function archive(SchoolContext $context, string $templateKey, string $reason): void; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult};
interface NotificationChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Communication\Support\RecipientSet;
interface RecipientDedupeServiceContract { public function dedupe(RecipientSet $recipients): RecipientSet; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\{RecipientQueryData,RecipientPreviewResult};
interface RecipientPreviewServiceContract { public function preview(SchoolContext $context, RecipientQueryData $query): RecipientPreviewResult; public function getPreview(SchoolContext $context, string $previewId): RecipientPreviewResult; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\RecipientQueryData; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
interface RecipientResolverContract { public function supports(RecipientQueryData $query): bool; public function resolve(SchoolContext $context, RecipientQueryData $query): RecipientSet; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult};
interface SmsChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult; }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult};
interface WhatsAppChannelContract { public function send(SchoolContext $context, ChannelMessageData $message): DeliveryResult; }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; use App\Domain\SchoolCore\Communication\Enums\CommunicationChannel; final readonly class BulkSendMessageData { public function __construct(public string $previewId, public CommunicationChannel $channel, public string $body, public ?string $subject, public ?string $templateId, public array $variables, public string $messageCategory, public string $priority, public ?string $scheduledAt, public ?string $idempotencyKey, public string $confirmationToken, public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class BulkSendResult { public function __construct(public int|string|null $messageId, public int $sent, public int $excluded, public int $failed, public array $results=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; use App\Domain\SchoolCore\Communication\Enums\CommunicationChannel; final readonly class ChannelMessageData { public function __construct(public CommunicationChannel $channel, public RecipientData $recipient, public string $body, public ?string $subject=null, public array $attachments=[], public ?int $messageId=null, public ?int $messageRecipientId=null, public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class CommunicationAuditData { public function __construct(public string $action, public string $entityType, public int|string $entityId, public ?array $before=null, public ?array $after=null, public ?string $reason=null, public ?string $idempotencyKey=null, public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class CommunicationPreferenceData { public function __construct(public string $recipientType, public int $recipientId, public string $channel, public bool $allowed, public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class ComposeMessageData { public function __construct(public ?string $templateId, public ?string $subject, public string $body, public array $variables, public string $messageCategory, public string $priority='normal', public ?string $language=null, public array $attachments=[], public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class DeliveryReceiptData { public function __construct(public int $messageRecipientId, public string $channel, public ?string $providerMessageId, public string $status, public ?string $failureReason=null, public array $rawProviderPayload=[], public ?string $deliveredAt=null, public ?string $failedAt=null) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class DeliveryResult { public function __construct(public string $status, public ?string $providerMessageId=null, public ?string $failureReason=null, public array $metadata=[]) {} public function successful(): bool { return $this->status==='sent'||$this->status==='queued'; } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class MessageTemplateData { public function __construct(public string $templateKey, public string $name, public string $body, public ?string $subject=null, public string $language='en', public array $requiredVariables=[], public string $status='active', public array $metadata=[]) {} }
@@ -0,0 +1,7 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\DTO;
final readonly class RecipientData {
public function __construct(public string $recipientType, public int|string $recipientId, public string $displayName, public ?string $email, public ?string $phone, public ?string $preferredLanguage, public array $guardianStudentIds, public ?int $staffId, public ?int $studentId, public int $schoolId, public array $deliveryChannels, public array $preferenceFlags = [], public array $metadata = [], public array $selectedBy = []) {}
public function fingerprint(): string { $email=$this->email?strtolower(trim($this->email)):null; $phone=$this->phone?preg_replace('/\D+/', '', $this->phone):null; return $this->recipientType.':'.(string)$this->recipientId.':'.($email ?: $phone ?: md5($this->displayName)); }
public function hasChannel(string $channel): bool { return in_array($channel, $this->deliveryChannels, true); }
}
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class RecipientPreviewData { public function __construct(public RecipientQueryData $query, public array $warnings=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; use App\Domain\SchoolCore\Communication\Support\RecipientSet; final readonly class RecipientPreviewResult { public function __construct(public string $previewId, public RecipientSet $recipients, public int $totalResolved, public int $totalAfterDedupe, public int $blockedByPreference, public int $missingContactMethod, public int $unauthorizedOrExcluded, public array $sampleRecipients, public array $warnings, public string $confirmationToken, public string $expiresAt) {} }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\DTO;
use App\Domain\SchoolCore\Communication\Enums\CommunicationChannel;
final readonly class RecipientQueryData { public function __construct(public string $audienceType, public array $audienceFilters, public CommunicationChannel $channel, public string $messageCategory, public bool $includeInactive=false, public bool $includeArchived=false, public bool $includeGuardians=true, public bool $includeStudents=false, public bool $includeStaff=false, public ?string $language=null, public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class ScheduledMessageResult { public function __construct(public int|string|null $messageId, public string $scheduledAt, public string $status='scheduled') {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; use App\Domain\SchoolCore\Communication\Enums\CommunicationChannel; final readonly class SendMessageData { public function __construct(public RecipientData $recipient, public CommunicationChannel $channel, public string $body, public ?string $subject=null, public ?string $templateId=null, public array $variables=[], public string $messageCategory='general', public string $priority='normal', public ?string $idempotencyKey=null, public array $metadata=[]) {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\DTO; final readonly class SendResult { public function __construct(public int|string|null $messageId, public DeliveryResult $delivery, public array $metadata=[]) {} }
@@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum CommunicationAuditAction: string {
case PREVIEW_CREATED = 'preview_created';
case MESSAGE_CREATED = 'message_created';
case BULK_SEND_CONFIRMED = 'bulk_send_confirmed';
case MESSAGE_SENT = 'message_sent';
case MESSAGE_SCHEDULED = 'message_scheduled';
case MESSAGE_CANCELLED = 'message_cancelled';
case DELIVERY_FAILED = 'delivery_failed';
case PREFERENCE_UPDATED = 'preference_updated';
case TEMPLATE_CREATED = 'template_created';
case TEMPLATE_UPDATED = 'template_updated';
case TEMPLATE_ARCHIVED = 'template_archived';
}
@@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum CommunicationChannel: string {
case EMAIL = 'email';
case SMS = 'sms';
case WHATSAPP = 'whatsapp';
case NOTIFICATION = 'notification';
}
@@ -0,0 +1,12 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum CommunicationStatus: string {
case DRAFT = 'draft';
case PREVIEWED = 'previewed';
case QUEUED = 'queued';
case SENT = 'sent';
case SCHEDULED = 'scheduled';
case CANCELLED = 'cancelled';
case FAILED = 'failed';
case PARTIAL = 'partial';
}
@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum DeliveryFailureReason: string {
case INVALID_CONTACT = 'invalid_contact';
case OPTED_OUT = 'opted_out';
case PROVIDER_TIMEOUT = 'provider_timeout';
case PROVIDER_FAILURE = 'provider_failure';
case RATE_LIMITED = 'rate_limited';
case UNAUTHORIZED = 'unauthorized';
case TEMPLATE_REJECTED = 'template_rejected';
}
@@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum MessageAudienceType: string {
case STUDENTS = 'students';
case GUARDIANS = 'guardians';
case STAFF = 'staff';
case SCHOOL_CLASS = 'class';
case SCHOOL_WIDE = 'school_wide';
case CUSTOM = 'custom';
}
@@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum MessagePriority: string {
case LOW = 'low';
case NORMAL = 'normal';
case HIGH = 'high';
case URGENT = 'urgent';
}
@@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Enums;
enum RecipientType: string {
case STUDENT = 'student';
case GUARDIAN = 'guardian';
case STAFF = 'staff';
case TEACHER = 'teacher';
case CUSTOM = 'custom';
}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
final class ChannelDeliveryException extends CommunicationException {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
class CommunicationException extends \RuntimeException {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
final class DuplicateSendException extends CommunicationException {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
final class MissingCommunicationPreferenceException extends CommunicationException {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
final class RecipientPreviewRequiredException extends CommunicationException {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
final class RecipientResolutionException extends CommunicationException {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Exceptions;
final class UnauthorizedCommunicationAccessException extends CommunicationException {}
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Policies;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\CommunicationPreferencePolicyContract; use App\Domain\SchoolCore\Communication\Enums\CommunicationChannel; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class DefaultCommunicationPreferencePolicy implements CommunicationPreferencePolicyContract { public function filterAllowedRecipients(SchoolContext $context, RecipientSet $recipients, CommunicationChannel $channel, string $category): RecipientSet { $allowed=[]; $excluded=$recipients->excluded; foreach($recipients->recipients as $r){ if($r->schoolId!==$context->schoolId){$excluded[]=['recipient'=>$r->displayName,'reason'=>'wrong_school']; continue;} if(!$r->hasChannel($channel->value)){ $excluded[]=['recipient'=>$r->displayName,'reason'=>'missing_channel']; continue;} $allowed[]=$r; } return new RecipientSet($allowed,$excluded); } public function canOverridePreferences(SchoolContext $context, string $category): bool { return $category==='emergency'; } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Policies; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\RecipientData; final class DefaultRecipientAuthorizationPolicy { public function canInclude(SchoolContext $context, RecipientData $recipient, string $category): bool { return $recipient->schoolId === $context->schoolId; } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Repositories;
final class CommunicationLogRepository {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Repositories;
final class MessageTemplateRepository {}
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Repositories;
final class RecipientReadRepository {}
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Resolvers;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\RecipientResolverContract; use App\Domain\SchoolCore\Communication\DTO\{RecipientData,RecipientQueryData}; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class ClassRecipientResolver implements RecipientResolverContract { public function supports(RecipientQueryData $query): bool { return $query->audienceType === 'class'; } public function resolve(SchoolContext $context, RecipientQueryData $query): RecipientSet { return new RecipientSet([new RecipientData('guardian', $context->schoolId * 1000 + 1, 'Class Guardian', 'class-guardian@example.test', '+15555550102', $query->language, [], null, null, $context->schoolId, [$query->channel->value], [], ['resolver'=>self::class], ['class'])]); } }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Resolvers;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\RecipientResolverContract; use App\Domain\SchoolCore\Communication\DTO\{RecipientData,RecipientQueryData}; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class GuardianRecipientResolver implements RecipientResolverContract { public function supports(RecipientQueryData $query): bool { return $query->audienceType === 'guardians'; } public function resolve(SchoolContext $context, RecipientQueryData $query): RecipientSet { return new RecipientSet([new RecipientData('guardian', $context->schoolId * 1000 + 1, 'Sample Guardian', 'guardian@example.test', '+15555550100', $query->language, [], null, null, $context->schoolId, [$query->channel->value], [], ['resolver'=>self::class], ['guardians'])]); } }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Resolvers;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\RecipientResolverContract; use App\Domain\SchoolCore\Communication\DTO\{RecipientData,RecipientQueryData}; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class SchoolWideRecipientResolver implements RecipientResolverContract { public function supports(RecipientQueryData $query): bool { return $query->audienceType === 'school_wide'; } public function resolve(SchoolContext $context, RecipientQueryData $query): RecipientSet { return new RecipientSet([new RecipientData('staff', $context->schoolId * 1000 + 1, 'School Staff', 'school@example.test', '+15555550103', $query->language, [], null, null, $context->schoolId, [$query->channel->value], [], ['resolver'=>self::class], ['school_wide'])]); } }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Resolvers;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\RecipientResolverContract; use App\Domain\SchoolCore\Communication\DTO\{RecipientData,RecipientQueryData}; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class StaffRecipientResolver implements RecipientResolverContract { public function supports(RecipientQueryData $query): bool { return $query->audienceType === 'staff'; } public function resolve(SchoolContext $context, RecipientQueryData $query): RecipientSet { return new RecipientSet([new RecipientData('staff', $context->schoolId * 1000 + 1, 'Sample Staff', 'staff@example.test', '+15555550101', $query->language, [], null, null, $context->schoolId, [$query->channel->value], [], ['resolver'=>self::class], ['staff'])]); } }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Resolvers;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\RecipientResolverContract; use App\Domain\SchoolCore\Communication\DTO\{RecipientData,RecipientQueryData}; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class StudentRecipientResolver implements RecipientResolverContract { public function supports(RecipientQueryData $query): bool { return $query->audienceType === 'students'; } public function resolve(SchoolContext $context, RecipientQueryData $query): RecipientSet { return new RecipientSet([new RecipientData('student', $context->schoolId * 1000 + 1, 'Sample Student', 'student@example.test', '', $query->language, [], null, null, $context->schoolId, [$query->channel->value], [], ['resolver'=>self::class], ['students'])]); } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Services; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\{ChannelRouterContract,EmailChannelContract,SmsChannelContract,WhatsAppChannelContract,NotificationChannelContract}; use App\Domain\SchoolCore\Communication\DTO\{ChannelMessageData,DeliveryResult}; final class ChannelRouter implements ChannelRouterContract { public function __construct(private EmailChannelContract $email, private SmsChannelContract $sms, private WhatsAppChannelContract $whatsApp, private NotificationChannelContract $notification) {} public function route(SchoolContext $context, ChannelMessageData $message): DeliveryResult { return match($message->channel->value){ 'email'=>$this->email->send($context,$message), 'sms'=>$this->sms->send($context,$message), 'whatsapp'=>$this->whatsApp->send($context,$message), 'notification'=>$this->notification->send($context,$message), default=>new DeliveryResult('failed',null,'unsupported_channel') }; } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Services; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\CommunicationAuditLoggerContract; use App\Domain\SchoolCore\Communication\DTO\CommunicationAuditData; final class CommunicationAuditLogger implements CommunicationAuditLoggerContract { public function log(SchoolContext $context, CommunicationAuditData $data): void {} }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Services; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\CommunicationIdempotencyServiceContract; use App\Domain\SchoolCore\Communication\Exceptions\DuplicateSendException; use Illuminate\Support\Facades\Cache; final class CommunicationIdempotencyService implements CommunicationIdempotencyServiceContract { public function rememberOrFail(SchoolContext $context, ?string $key, string $operation, array $payload, callable $callback): mixed { if(!$key){ return $callback(); } $cacheKey='comm_idem:'.$context->schoolId.':'.$context->actorUserId.':'.$operation.':'.$key; $hash=hash('sha256',json_encode($payload)); $stored=Cache::get($cacheKey); if($stored){ if(($stored['hash']??null)!==$hash){ throw new DuplicateSendException('Idempotency key reused with different payload.'); } return $stored['result']; } $result=$callback(); Cache::put($cacheKey,['hash'=>$hash,'result'=>$result],now()->addDay()); return $result; } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Services; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\{CommunicationServiceContract,RecipientPreviewServiceContract,ChannelRouterContract,CommunicationIdempotencyServiceContract}; use App\Domain\SchoolCore\Communication\DTO\{BulkSendMessageData,BulkSendResult,ChannelMessageData,ScheduledMessageResult,SendMessageData,SendResult,RecipientData}; use App\Domain\SchoolCore\Communication\Exceptions\RecipientPreviewRequiredException; use Illuminate\Support\Facades\DB; final class CommunicationService implements CommunicationServiceContract { public function __construct(private RecipientPreviewServiceContract $previews, private ChannelRouterContract $router, private CommunicationIdempotencyServiceContract $idempotency) {} public function sendMessage(SchoolContext $context, SendMessageData $data): SendResult { return $this->idempotency->rememberOrFail($context,$data->idempotencyKey,'communication.send.single',(array)$data,function() use($context,$data){ $delivery=$this->router->route($context,new ChannelMessageData($data->channel,$data->recipient,$data->body,$data->subject)); return new SendResult(null,$delivery); }); } public function sendBulkMessage(SchoolContext $context, BulkSendMessageData $data): BulkSendResult { if($data->previewId===''||$data->confirmationToken===''){ throw new RecipientPreviewRequiredException('Bulk send requires preview_id and confirmation_token.'); } return $this->idempotency->rememberOrFail($context,$data->idempotencyKey,'communication.send.bulk',(array)$data,function() use($context,$data){ $row=DB::table('communication_recipient_previews')->where('school_id',$context->schoolId)->where('id',$data->previewId)->first(); if(!$row || hash('sha256',$data->confirmationToken)!==$row->confirmation_token_hash || now()->greaterThan($row->expires_at)){ throw new RecipientPreviewRequiredException('Preview missing, expired, or token mismatch.'); } $preview=$this->previews->getPreview($context,$data->previewId); $sent=0; $failed=0; $results=[]; foreach($preview->recipients->recipients as $recipient){ $r=is_array($recipient) ? new RecipientData(...array_values($recipient)) : $recipient; $delivery=$this->router->route($context,new ChannelMessageData($data->channel,$r,$data->body,$data->subject)); $delivery->successful() ? $sent++ : $failed++; $results[]=$delivery; } return new BulkSendResult(null,$sent,count($preview->recipients->excluded),$failed,$results); }); } public function scheduleBulkMessage(SchoolContext $context, BulkSendMessageData $data): ScheduledMessageResult { if($data->scheduledAt===null){ throw new RecipientPreviewRequiredException('scheduled_at is required.'); } return new ScheduledMessageResult(null,$data->scheduledAt); } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace App\Domain\SchoolCore\Communication\Services; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\DeliveryStatusServiceContract; use App\Domain\SchoolCore\Communication\DTO\DeliveryReceiptData; final class DeliveryStatusService implements DeliveryStatusServiceContract { public function recordReceipt(SchoolContext $context, DeliveryReceiptData $receipt): array { return ['status'=>$receipt->status]; } public function markFailed(SchoolContext $context, int $messageRecipientId, string $reason): void {} }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Services;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\DTO\MessageTemplateData;
final class MessageTemplateService implements \App\Domain\SchoolCore\Communication\Contracts\MessageTemplateServiceContract { public function create(SchoolContext $context, MessageTemplateData $data): array { return ['template_key'=>$data->templateKey]; } public function render(SchoolContext $context, string $templateKey, array $variables=[], ?string $language=null): string { return ''; } public function archive(SchoolContext $context, string $templateKey, string $reason): void {} }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Services;
use App\Domain\SchoolCore\Communication\Contracts\RecipientDedupeServiceContract; use App\Domain\SchoolCore\Communication\Support\RecipientSet;
final class RecipientDedupeService implements RecipientDedupeServiceContract { public function dedupe(RecipientSet $recipients): RecipientSet { $seen=[]; $kept=[]; $excluded=$recipients->excluded; foreach($recipients->recipients as $recipient){ $key=$recipient->fingerprint(); if(isset($seen[$key])){ $excluded[]=['recipient'=>$recipient->displayName,'reason'=>'duplicate']; continue; } $seen[$key]=true; $kept[]=$recipient; } return new RecipientSet($kept,$excluded); } }
@@ -0,0 +1,4 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Services;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Communication\Contracts\{RecipientPreviewServiceContract,RecipientDedupeServiceContract,CommunicationPreferencePolicyContract,RecipientResolverContract}; use App\Domain\SchoolCore\Communication\DTO\{RecipientQueryData,RecipientPreviewResult}; use App\Domain\SchoolCore\Communication\Exceptions\RecipientResolutionException; use App\Domain\SchoolCore\Communication\Support\RecipientSet; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str;
final class RecipientPreviewService implements RecipientPreviewServiceContract { public function __construct(private iterable $resolvers, private RecipientDedupeServiceContract $dedupe, private CommunicationPreferencePolicyContract $preferences) {} public function preview(SchoolContext $context, RecipientQueryData $query): RecipientPreviewResult { $resolved=RecipientSet::empty(); foreach($this->resolvers as $resolver){ if($resolver->supports($query)){ $resolved=$resolved->merge($resolver->resolve($context,$query)); } } if($resolved->count()===0){ throw new RecipientResolutionException('No resolver produced recipients.'); } $deduped=$this->dedupe->dedupe($resolved); $allowed=$this->preferences->filterAllowedRecipients($context,$deduped,$query->channel,$query->messageCategory); $previewId=(string)Str::uuid(); $token=bin2hex(random_bytes(16)); $expiresAt=now()->addMinutes(30)->toDateTimeString(); DB::table('communication_recipient_previews')->insert(['id'=>$previewId,'school_id'=>$context->schoolId,'actor_user_id'=>$context->actorUserId,'audience_type'=>$query->audienceType,'audience_filters_json'=>json_encode($query->audienceFilters),'channel'=>$query->channel->value,'message_category'=>$query->messageCategory,'recipient_count'=>$resolved->count(),'deduped_count'=>$deduped->count(),'blocked_count'=>count($allowed->excluded),'missing_contact_count'=>0,'snapshot_json'=>json_encode(['recipients'=>$allowed->recipients,'excluded'=>$allowed->excluded]),'confirmation_token_hash'=>hash('sha256',$token),'expires_at'=>$expiresAt,'created_at'=>now(),'updated_at'=>now()]); return new RecipientPreviewResult($previewId,$allowed,$resolved->count(),$deduped->count(),count($allowed->excluded),0,0,$allowed->sample(),[],$token,$expiresAt); } public function getPreview(SchoolContext $context, string $previewId): RecipientPreviewResult { $row=DB::table('communication_recipient_previews')->where('school_id',$context->schoolId)->where('id',$previewId)->first(); if(!$row){ throw new RecipientResolutionException('Preview not found.'); } $snap=json_decode((string)$row->snapshot_json,true)?:[]; $set=new RecipientSet($snap['recipients']??[],$snap['excluded']??[]); return new RecipientPreviewResult((string)$row->id,$set,(int)$row->recipient_count,(int)$row->deduped_count,(int)$row->blocked_count,(int)$row->missing_contact_count,0,$set->sample(),[],'',(string)$row->expires_at); } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Support;
final class CommunicationTransactionRunner { public function run(callable $callback): mixed { return \Illuminate\Support\Facades\DB::transaction($callback); } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Support;
final class DeliveryRetryPolicy { public function shouldRetry(string $failureReason, int $attempts): bool { return $attempts < 3 && in_array($failureReason, ['provider_timeout','provider_failure','rate_limited'], true); } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Support;
final class MessageSanitizer { public function sanitize(string $body): string { return strip_tags($body, '<p><br><strong><em><ul><ol><li><a>'); } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Support;
final class RecipientFingerprint { public function forRecipient(\App\Domain\SchoolCore\Communication\DTO\RecipientData $recipient): string { return hash('sha256', $recipient->schoolId.'|'.$recipient->fingerprint()); } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Support;
final readonly class RecipientSet { public function __construct(public array $recipients, public array $excluded=[]) {} public static function empty(): self { return new self([]); } public function count(): int { return count($this->recipients); } public function merge(self $other): self { return new self(array_values(array_merge($this->recipients,$other->recipients)), array_merge($this->excluded,$other->excluded)); } public function sample(int $limit=10): array { return array_slice($this->recipients,0,$limit); } }
@@ -0,0 +1,3 @@
<?php declare(strict_types=1);
namespace App\Domain\SchoolCore\Communication\Support;
final class TemplateRenderer { public function render(string $template, array $variables, array $required=[]): string { foreach ($required as $key) { if (!array_key_exists($key,$variables)) { throw new \InvalidArgumentException('Missing template variable: '.$key); } } return preg_replace_callback('/{{\s*([a-zA-Z0-9_\.]+)\s*}}/', fn($m)=>htmlspecialchars((string)($variables[$m[1]]??''), ENT_QUOTES, 'UTF-8'), $template) ?? $template; } }