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,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Academics\Policies;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Contracts\GradeScalePolicyContract;
final class DefaultGradeScalePolicy implements GradeScalePolicyContract
{
public function gradeForScore(SchoolContext $context, float $score): string
{
return match (true) { $score >= 90 => 'A', $score >= 80 => 'B', $score >= 70 => 'C', $score >= 60 => 'D', default => 'F' };
}
public function passingScore(SchoolContext $context): float { return 60.0; }
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Academics\Policies;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Contracts\PromotionPolicyContract;
final class DefaultPromotionPolicy implements PromotionPolicyContract
{
public function canPromote(SchoolContext $context, int $studentId): bool { return false; }
public function nextPlacement(SchoolContext $context, int $studentId): array { return []; }
}
@@ -0,0 +1,3 @@
# Academics
Neutral SchoolCore module boundary. Keep domain-specific vocabulary in extension modules.
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceAuditData;
interface AttendanceAuditLoggerContract
{
public function log(SchoolContext $context, AttendanceAuditData $data): void;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceIdempotencyData;
use App\Domain\SchoolCore\Attendance\DTO\ProcessScanData;
use App\Domain\SchoolCore\Attendance\DTO\ScanResult;
interface AttendanceIdempotencyServiceContract
{
public function findExisting(SchoolContext $context, AttendanceIdempotencyData $data): ?ScanResult;
public function storeResult(SchoolContext $context, AttendanceIdempotencyData $data, ProcessScanData $scan, ScanResult $result): void;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSubjectView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
interface AttendancePolicyContract
{
public function canRecordAttendance(SchoolContext $context, AttendanceSubjectView $subject, AttendanceSessionView $session): bool;
public function duplicateScanWindowSeconds(SchoolContext $context): int;
public function allowCheckout(SchoolContext $context): bool;
public function allowManualOverride(SchoolContext $context): bool;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSummaryQuery;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSummaryResult;
interface AttendanceServiceContract
{
public function getSessionAttendance(SchoolContext $context, int $sessionId): AttendanceSessionView;
public function summarizeAttendance(SchoolContext $context, AttendanceSummaryQuery $query): AttendanceSummaryResult;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSubjectView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use DateTimeInterface;
interface AttendanceSessionResolverContract
{
public function resolveForScan(
SchoolContext $context,
AttendanceSubjectView $subject,
DateTimeInterface $timestamp
): AttendanceSessionView;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceStatusResult;
use DateTimeInterface;
interface AttendanceStatusPolicyContract
{
public function statusForScan(
SchoolContext $context,
AttendanceSessionView $session,
DateTimeInterface $timestamp
): AttendanceStatusResult;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupData;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupResult;
interface BadgeLookupServiceContract
{
public function lookup(SchoolContext $context, BadgeLookupData $data): BadgeLookupResult;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\ProcessScanData;
use App\Domain\SchoolCore\Attendance\DTO\ScanResult;
interface ScannerServiceContract
{
public function processScan(SchoolContext $context, ProcessScanData $data): ScanResult;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\RecordStaffAttendanceData;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceRecordResult;
interface StaffAttendanceServiceContract
{
public function recordStaffAttendance(SchoolContext $context, RecordStaffAttendanceData $data): AttendanceRecordResult;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Contracts;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\RecordStudentAttendanceData;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceRecordResult;
interface StudentAttendanceServiceContract
{
public function recordStudentAttendance(SchoolContext $context, RecordStudentAttendanceData $data): AttendanceRecordResult;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceActorData
{
public function __construct(
public int $actorUserId,
public array $roleIds = [],
public string $actorType = 'user',
) {}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceAuditData
{
public function __construct(
public string $action,
public string $subjectType,
public int $subjectId,
public ?int $attendanceSessionId = null,
public ?int $attendanceRecordId = null,
public ?array $before = null,
public ?array $after = null,
public ?string $reason = null,
public string $source = 'system',
public ?string $stationId = null,
public ?string $requestId = null,
public ?string $idempotencyKey = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceIdempotencyData
{
public function __construct(
public string $operation,
public string $key,
public string $payloadHash,
public ?string $fingerprint = null,
) {}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceRecordResult
{
public function __construct(
public int $recordId,
public string $subjectType,
public int $subjectId,
public string $status,
public bool $created,
public array $payload = [],
) {}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
use DateTimeImmutable;
final readonly class AttendanceSessionData
{
public function __construct(
public string $name,
public string $sessionType,
public DateTimeImmutable $startsAt,
public DateTimeImmutable $endsAt,
public string $timezone,
public array $metadata = [],
) {}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
use DateTimeImmutable;
final readonly class AttendanceSessionView
{
public function __construct(
public int $id,
public int $schoolId,
public string $name,
public string $sessionType,
public DateTimeImmutable $startsAt,
public DateTimeImmutable $endsAt,
public string $timezone,
public string $status = 'open',
public array $metadata = [],
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceStatusData
{
public function __construct(
public string $status,
public string $source,
public ?string $reason = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceStatusResult
{
public function __construct(
public string $status,
public bool $late = false,
public ?string $reason = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceSubjectView
{
public function __construct(
public string $subjectType,
public int $subjectId,
public int $schoolId,
public string $displayName,
public bool $active = true,
public array $metadata = [],
) {}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceSummaryQuery
{
public function __construct(
public ?string $fromDate = null,
public ?string $toDate = null,
public ?int $sessionId = null,
public ?string $subjectType = null,
public array $filters = [],
) {}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class AttendanceSummaryResult
{
public function __construct(
public array $counts,
public array $rows = [],
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class BadgeLookupData
{
public function __construct(
public string $badgeValue,
public ?string $badgeType = null,
public ?string $subjectTypeHint = null,
public ?string $stationId = null,
) {}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class BadgeLookupResult
{
public function __construct(
public AttendanceSubjectView $subject,
public ?int $badgeId = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
use DateTimeImmutable;
final readonly class ProcessScanData
{
public function __construct(
public string $badgeValue,
public ?string $stationId,
public string $scanAction,
public string $scanSource,
public DateTimeImmutable $scannedAt,
public ?string $deviceId = null,
public ?string $clientRequestId = null,
public ?string $idempotencyKey = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
use DateTimeImmutable;
final readonly class RecordStaffAttendanceData
{
public function __construct(
public int $staffId,
public ?int $sessionId,
public string $attendanceDate,
public string $status,
public string $source,
public DateTimeImmutable $recordedAt,
public ?string $reason = null,
public ?string $notes = null,
public ?string $overrideReason = null,
public ?string $idempotencyKey = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
use DateTimeImmutable;
final readonly class RecordStudentAttendanceData
{
public function __construct(
public int $studentId,
public ?int $sessionId,
public string $attendanceDate,
public string $status,
public string $source,
public DateTimeImmutable $recordedAt,
public ?string $reason = null,
public ?string $notes = null,
public ?string $overrideReason = null,
public ?string $idempotencyKey = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\DTO;
final readonly class ScanResult
{
public function __construct(
public bool $accepted,
public string $status,
public string $message,
public ?AttendanceSubjectView $subject = null,
public ?AttendanceSessionView $session = null,
public ?int $attendanceRecordId = null,
public bool $duplicate = false,
public array $payload = [],
) {}
public function toArray(): array
{
return [
'accepted' => $this->accepted,
'status' => $this->status,
'message' => $this->message,
'subject' => $this->subject ? [
'type' => $this->subject->subjectType,
'id' => $this->subject->subjectId,
'name' => $this->subject->displayName,
] : null,
'session' => $this->session ? [
'id' => $this->session->id,
'name' => $this->session->name,
'type' => $this->session->sessionType,
] : null,
'attendance_record_id' => $this->attendanceRecordId,
'duplicate' => $this->duplicate,
'payload' => $this->payload,
];
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Data;
use DateTimeInterface;
final readonly class AttendanceMarkData
{
public function __construct(public int $studentId, public string $status, public DateTimeInterface $markedAt) {}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Data;
final readonly class AttendanceResult
{
/** @param array<string, mixed> $details */
public function __construct(public bool $accepted, public string $status, public array $details = []) {}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Data;
use DateTimeInterface;
final readonly class AttendanceScanData
{
public function __construct(public string $scanCode, public DateTimeInterface $scannedAt) {}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Enums;
enum AttendanceAuditAction: string
{
case SCAN_PROCESSED = 'attendance.scan.processed';
case SCAN_DUPLICATE = 'attendance.scan.duplicate';
case STUDENT_RECORDED = 'attendance.student.recorded';
case STUDENT_EDITED = 'attendance.student.edited';
case STAFF_RECORDED = 'attendance.staff.recorded';
case STAFF_EDITED = 'attendance.staff.edited';
case STATUS_OVERRIDDEN = 'attendance.status.overridden';
case SESSION_CREATED = 'attendance.session.created';
case SESSION_CLOSED = 'attendance.session.closed';
case BULK_UPDATED = 'attendance.bulk.updated';
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Enums;
enum AttendanceSessionStatus: string
{
case OPEN = 'open';
case CLOSED = 'closed';
case CANCELLED = 'cancelled';
case DRAFT = 'draft';
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Enums;
enum AttendanceStatus: string
{
case PRESENT = 'present';
case LATE = 'late';
case ABSENT = 'absent';
case EXCUSED = 'excused';
case UNEXCUSED = 'unexcused';
case CHECKED_IN = 'checked_in';
case CHECKED_OUT = 'checked_out';
case UNKNOWN = 'unknown';
case LEFT_EARLY = 'left_early';
case REMOTE = 'remote';
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Enums;
enum AttendanceSubjectType: string
{
case STUDENT = 'student';
case STAFF = 'staff';
case VOLUNTEER = 'volunteer';
case VISITOR = 'visitor';
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Enums;
enum ScanAction: string
{
case CHECK_IN = 'check_in';
case CHECK_OUT = 'check_out';
case LOOKUP = 'lookup';
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Enums;
enum ScanSource: string
{
case SCANNER = 'scanner';
case MANUAL = 'manual';
case IMPORT = 'import';
case API = 'api';
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class AttendanceConcurrencyException extends AttendanceException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class AttendanceException extends \RuntimeException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class AttendanceSessionNotFoundException extends AttendanceException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class BadgeNotFoundException extends AttendanceException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class DuplicateScanException extends AttendanceException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class InvalidAttendanceStatusException extends AttendanceException
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Exceptions;
class UnauthorizedAttendanceAccessException extends AttendanceException
{
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Policies;
use App\Domain\SchoolCore\Attendance\Contracts\AttendancePolicyContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSubjectView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Context\SchoolContext;
final class DefaultAttendancePolicy implements AttendancePolicyContract
{
public function canRecordAttendance(SchoolContext $context, AttendanceSubjectView $subject, AttendanceSessionView $session): bool
{
return $subject->schoolId === $context->schoolId && $session->schoolId === $context->schoolId && $subject->active;
}
public function duplicateScanWindowSeconds(SchoolContext $context): int
{
return 90;
}
public function allowCheckout(SchoolContext $context): bool
{
return true;
}
public function allowManualOverride(SchoolContext $context): bool
{
return true;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Policies;
final class DefaultAttendanceSessionPolicy
{
public function mayOpenSession(): bool
{
return true;
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Policies;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceStatusPolicyContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceStatusResult;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeInterface;
final class DefaultAttendanceStatusPolicy implements AttendanceStatusPolicyContract
{
public function statusForScan(SchoolContext $context, AttendanceSessionView $session, DateTimeInterface $timestamp): AttendanceStatusResult
{
$lateThreshold = $session->startsAt->modify('+10 minutes');
$status = $timestamp > $lateThreshold ? 'late' : 'present';
return new AttendanceStatusResult($status, $status === 'late');
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Policies;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Contracts\AttendancePolicyContract;
use DateTimeInterface;
final class StandardSchoolAttendancePolicy implements AttendancePolicyContract
{
public function isSchoolDay(SchoolContext $context, DateTimeInterface $date): bool
{
return ! in_array((int) $date->format('N'), [6, 7], true);
}
public function determineStatus(SchoolContext $context, DateTimeInterface $scanTime, array $session): string
{
$startsAt = $session['starts_at'] ?? null;
if (! $startsAt instanceof DateTimeInterface) {
return 'present';
}
return $scanTime <= $startsAt ? 'present' : 'late';
}
public function duplicateScanWindowSeconds(SchoolContext $context): int
{
return 300;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Providers;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Contracts\AcademicCalendarProviderContract;
use DateTimeInterface;
final class StandardAcademicCalendarProvider implements AcademicCalendarProviderContract
{
public function currentAcademicYear(SchoolContext $context): ?int
{
return is_numeric($context->schoolYear) ? (int) $context->schoolYear : null;
}
public function currentTerm(SchoolContext $context): ?int
{
return is_numeric($context->term) ? (int) $context->term : null;
}
public function sessionsForDate(SchoolContext $context, DateTimeInterface $date): array
{
return [];
}
}
@@ -0,0 +1,12 @@
# SchoolCore Attendance
Phase 4 extracts scanner and attendance behavior into neutral, context-aware services.
Rules:
- every public mutation accepts `SchoolContext`;
- controllers validate and delegate only;
- scanner writes go through `ScannerServiceContract`;
- badge lookup is school-scoped;
- duplicate scans use idempotency/fingerprints;
- status and session rules are policy/provider driven;
- `SchoolCore\Attendance` must not import `IslamicSundaySchool`.
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Repositories;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSummaryQuery;
use Illuminate\Support\Facades\DB;
final class AttendanceReadRepository
{
public function summarize(SchoolContext $context, AttendanceSummaryQuery $query): array
{
$builder = DB::table('attendance_records')->where('school_id', $context->schoolId);
if ($query->sessionId !== null) {
$builder->where('attendance_session_id', $query->sessionId);
}
if ($query->fromDate !== null) {
$builder->where('attendance_date', '>=', $query->fromDate);
}
if ($query->toDate !== null) {
$builder->where('attendance_date', '<=', $query->toDate);
}
if ($query->subjectType !== null) {
$builder->where('subject_type', $query->subjectType);
}
return $builder->selectRaw('status, count(*) as total')->groupBy('status')->pluck('total', 'status')->all();
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Repositories;
use App\Domain\SchoolCore\Context\SchoolContext;
use Illuminate\Support\Facades\DB;
final class AttendanceRepository
{
public function upsertRecord(SchoolContext $context, array $attributes, array $values): object
{
$query = DB::table('attendance_records')
->where('school_id', $context->schoolId);
foreach ($attributes as $column => $value) {
$query->where($column, $value);
}
$existing = $query->lockForUpdate()->first();
if ($existing) {
DB::table('attendance_records')->where('id', $existing->id)->update($values + ['updated_at' => now()]);
return DB::table('attendance_records')->where('id', $existing->id)->first();
}
$id = DB::table('attendance_records')->insertGetId($attributes + $values + [
'school_id' => $context->schoolId,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('attendance_records')->where('id', $id)->first();
}
public function createScanLog(SchoolContext $context, array $values): int
{
return (int) DB::table('attendance_scan_logs')->insertGetId($values + [
'school_id' => $context->schoolId,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Repositories;
use App\Domain\SchoolCore\Context\SchoolContext;
use Illuminate\Support\Facades\DB;
final class AttendanceSessionRepository
{
public function currentForTimestamp(SchoolContext $context, \DateTimeInterface $timestamp): ?object
{
return DB::table('attendance_sessions')
->where('school_id', $context->schoolId)
->where('starts_at', '<=', $timestamp->format('Y-m-d H:i:s'))
->where('ends_at', '>=', $timestamp->format('Y-m-d H:i:s'))
->where('status', 'open')
->orderBy('starts_at')
->first();
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Repositories;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSubjectView;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupData;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupResult;
use App\Domain\SchoolCore\Attendance\Exceptions\BadgeNotFoundException;
use Illuminate\Support\Facades\DB;
final class BadgeRepository
{
public function lookup(SchoolContext $context, BadgeLookupData $data): BadgeLookupResult
{
$badge = DB::table('attendance_badges')
->where('school_id', $context->schoolId)
->where('badge_value', $data->badgeValue)
->where('active', true)
->first();
if (! $badge) {
throw new BadgeNotFoundException('Badge was not found for this school.');
}
return new BadgeLookupResult(new AttendanceSubjectView(
subjectType: (string) $badge->subject_type,
subjectId: (int) $badge->subject_id,
schoolId: (int) $badge->school_id,
displayName: (string) ($badge->display_name ?? ucfirst((string) $badge->subject_type).' #'.$badge->subject_id),
active: (bool) $badge->active,
metadata: ['badge_value' => $data->badgeValue],
), (int) $badge->id);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceAuditLoggerContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceAuditData;
use App\Domain\SchoolCore\Context\SchoolContext;
use Illuminate\Support\Facades\DB;
final class AttendanceAuditLogger implements AttendanceAuditLoggerContract
{
public function log(SchoolContext $context, AttendanceAuditData $data): void
{
DB::table('attendance_audit_logs')->insert([
'school_id' => $context->schoolId,
'academic_year_id' => is_numeric($context->schoolYear) ? (int) $context->schoolYear : null,
'term_id' => is_numeric($context->term) ? (int) $context->term : null,
'actor_user_id' => $context->actorUserId,
'actor_type' => 'user',
'action' => $data->action,
'subject_type' => $data->subjectType,
'subject_id' => $data->subjectId,
'attendance_session_id' => $data->attendanceSessionId,
'attendance_record_id' => $data->attendanceRecordId,
'before_json' => $data->before ? json_encode($data->before, JSON_THROW_ON_ERROR) : null,
'after_json' => $data->after ? json_encode($data->after, JSON_THROW_ON_ERROR) : null,
'reason' => $data->reason,
'source' => $data->source,
'station_id' => $data->stationId,
'request_id' => $data->requestId,
'idempotency_key' => $data->idempotencyKey,
'metadata_json' => json_encode($data->metadata, JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceIdempotencyServiceContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceIdempotencyData;
use App\Domain\SchoolCore\Attendance\DTO\ProcessScanData;
use App\Domain\SchoolCore\Attendance\DTO\ScanResult;
use App\Domain\SchoolCore\Attendance\Exceptions\DuplicateScanException;
use App\Domain\SchoolCore\Context\SchoolContext;
use Illuminate\Support\Facades\DB;
final class AttendanceIdempotencyService implements AttendanceIdempotencyServiceContract
{
public function findExisting(SchoolContext $context, AttendanceIdempotencyData $data): ?ScanResult
{
$row = DB::table('attendance_idempotency_keys')
->where('school_id', $context->schoolId)
->where('operation', $data->operation)
->where('idempotency_key', $data->key)
->first();
if (! $row) {
return null;
}
if ((string) $row->payload_hash !== $data->payloadHash) {
throw new DuplicateScanException('Same attendance idempotency key was reused with a different payload.');
}
$payload = json_decode((string) $row->result_json, true) ?: [];
return new ScanResult(
accepted: (bool) ($payload['accepted'] ?? true),
status: (string) ($payload['status'] ?? 'duplicate'),
message: (string) ($payload['message'] ?? 'Duplicate scan returned prior result.'),
duplicate: true,
payload: $payload,
);
}
public function storeResult(SchoolContext $context, AttendanceIdempotencyData $data, ProcessScanData $scan, ScanResult $result): void
{
DB::table('attendance_idempotency_keys')->updateOrInsert([
'school_id' => $context->schoolId,
'operation' => $data->operation,
'idempotency_key' => $data->key,
], [
'payload_hash' => $data->payloadHash,
'fingerprint' => $data->fingerprint,
'result_json' => json_encode($result->toArray(), JSON_THROW_ON_ERROR),
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceServiceContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSummaryQuery;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSummaryResult;
use App\Domain\SchoolCore\Attendance\Repositories\AttendanceReadRepository;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
final class AttendanceService implements AttendanceServiceContract
{
public function __construct(private AttendanceReadRepository $reads) {}
public function getSessionAttendance(SchoolContext $context, int $sessionId): AttendanceSessionView
{
$session = DB::table('attendance_sessions')->where('school_id', $context->schoolId)->whereKey($sessionId)->first();
if (! $session) {
throw new \RuntimeException('Attendance session not found.');
}
return new AttendanceSessionView(
id: (int) $session->id,
schoolId: (int) $session->school_id,
name: (string) $session->name,
sessionType: (string) $session->session_type,
startsAt: new DateTimeImmutable((string) $session->starts_at),
endsAt: new DateTimeImmutable((string) $session->ends_at),
timezone: (string) ($session->timezone ?? $context->timezone),
status: (string) $session->status,
);
}
public function summarizeAttendance(SchoolContext $context, AttendanceSummaryQuery $query): AttendanceSummaryResult
{
return new AttendanceSummaryResult($this->reads->summarize($context, $query));
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceSessionResolverContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSubjectView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeInterface;
use RuntimeException;
final class AttendanceSessionResolver implements AttendanceSessionResolverContract
{
public function resolveForScan(
SchoolContext $context,
AttendanceSubjectView $subject,
DateTimeInterface $timestamp
): AttendanceSessionView {
throw new RuntimeException('Attendance session resolver is not implemented yet.');
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceStatusPolicyContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceStatusResult;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeInterface;
use RuntimeException;
final class AttendanceStatusPolicy implements AttendanceStatusPolicyContract
{
public function statusForScan(
SchoolContext $context,
AttendanceSessionView $session,
DateTimeInterface $timestamp
): AttendanceStatusResult {
throw new RuntimeException('Attendance status policy is not implemented yet.');
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceStatusPolicyContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceStatusResult;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeInterface;
final class AttendanceStatusResolver
{
public function __construct(private AttendanceStatusPolicyContract $policy) {}
public function forScan(SchoolContext $context, AttendanceSessionView $session, DateTimeInterface $timestamp): AttendanceStatusResult
{
return $this->policy->statusForScan($context, $session, $timestamp);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\BadgeLookupServiceContract;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupData;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupResult;
use App\Domain\SchoolCore\Attendance\Repositories\BadgeRepository;
use App\Domain\SchoolCore\Context\SchoolContext;
final class BadgeLookupService implements BadgeLookupServiceContract
{
public function __construct(private BadgeRepository $badges) {}
public function lookup(SchoolContext $context, BadgeLookupData $data): BadgeLookupResult
{
return $this->badges->lookup($context, $data);
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use DateTimeInterface;
final class LateArrivalEvaluator
{
public function isLate(AttendanceSessionView $session, DateTimeInterface $timestamp, int $graceMinutes = 10): bool
{
return $timestamp > $session->startsAt->modify('+'.$graceMinutes.' minutes');
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceAuditLoggerContract;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceIdempotencyServiceContract;
use App\Domain\SchoolCore\Attendance\Contracts\AttendancePolicyContract;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceSessionResolverContract;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceStatusPolicyContract;
use App\Domain\SchoolCore\Attendance\Contracts\BadgeLookupServiceContract;
use App\Domain\SchoolCore\Attendance\Contracts\ScannerServiceContract;
use App\Domain\SchoolCore\Attendance\Contracts\StaffAttendanceServiceContract;
use App\Domain\SchoolCore\Attendance\Contracts\StudentAttendanceServiceContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceAuditData;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceIdempotencyData;
use App\Domain\SchoolCore\Attendance\DTO\BadgeLookupData;
use App\Domain\SchoolCore\Attendance\DTO\ProcessScanData;
use App\Domain\SchoolCore\Attendance\DTO\RecordStaffAttendanceData;
use App\Domain\SchoolCore\Attendance\DTO\RecordStudentAttendanceData;
use App\Domain\SchoolCore\Attendance\DTO\ScanResult;
use App\Domain\SchoolCore\Attendance\Exceptions\UnauthorizedAttendanceAccessException;
use App\Domain\SchoolCore\Attendance\Support\ScanFingerprint;
use App\Domain\SchoolCore\Context\SchoolContext;
final class ScannerService implements ScannerServiceContract
{
public function __construct(
private BadgeLookupServiceContract $badges,
private AttendanceSessionResolverContract $sessions,
private AttendanceStatusPolicyContract $statuses,
private AttendancePolicyContract $policy,
private AttendanceIdempotencyServiceContract $idempotency,
private StudentAttendanceServiceContract $students,
private StaffAttendanceServiceContract $staff,
private AttendanceAuditLoggerContract $audit,
) {}
public function processScan(SchoolContext $context, ProcessScanData $data): ScanResult
{
$window = $this->policy->duplicateScanWindowSeconds($context);
$fingerprint = ScanFingerprint::fromScan($context, $data, $window);
$key = $data->idempotencyKey ?: $data->clientRequestId ?: $fingerprint;
$idem = new AttendanceIdempotencyData('scanner.process', $key, ScanFingerprint::payloadHash($data), $fingerprint);
if ($existing = $this->idempotency->findExisting($context, $idem)) {
$this->audit->log($context, new AttendanceAuditData(
action: 'attendance.scan.duplicate',
subjectType: 'unknown',
subjectId: 0,
source: $data->scanSource,
stationId: $data->stationId,
idempotencyKey: $key,
));
return $existing;
}
$badge = $this->badges->lookup($context, new BadgeLookupData($data->badgeValue, stationId: $data->stationId));
$session = $this->sessions->resolveForScan($context, $badge->subject, $data->scannedAt);
if (! $this->policy->canRecordAttendance($context, $badge->subject, $session)) {
throw new UnauthorizedAttendanceAccessException('Actor may not record attendance for this subject/session.');
}
$status = $this->statuses->statusForScan($context, $session, $data->scannedAt);
$date = $data->scannedAt->setTimezone(new \DateTimeZone($session->timezone))->format('Y-m-d');
$record = null;
if ($badge->subject->subjectType === 'student') {
$record = $this->students->recordStudentAttendance($context, new RecordStudentAttendanceData(
studentId: $badge->subject->subjectId,
sessionId: $session->id,
attendanceDate: $date,
status: $status->status,
source: $data->scanSource,
recordedAt: $data->scannedAt,
idempotencyKey: $key,
metadata: $data->metadata,
));
} elseif ($badge->subject->subjectType === 'staff' || $badge->subject->subjectType === 'volunteer') {
$record = $this->staff->recordStaffAttendance($context, new RecordStaffAttendanceData(
staffId: $badge->subject->subjectId,
sessionId: $session->id,
attendanceDate: $date,
status: $status->status,
source: $data->scanSource,
recordedAt: $data->scannedAt,
idempotencyKey: $key,
metadata: $data->metadata,
));
}
$result = new ScanResult(
accepted: true,
status: $status->status,
message: $status->late ? 'Scan accepted. Subject is late.' : 'Scan accepted.',
subject: $badge->subject,
session: $session,
attendanceRecordId: $record?->recordId,
duplicate: false,
payload: ['late' => $status->late],
);
$this->audit->log($context, new AttendanceAuditData(
action: 'attendance.scan.processed',
subjectType: $badge->subject->subjectType,
subjectId: $badge->subject->subjectId,
attendanceSessionId: $session->id,
attendanceRecordId: $record?->recordId,
after: $result->toArray(),
source: $data->scanSource,
stationId: $data->stationId,
idempotencyKey: $key,
));
$this->idempotency->storeResult($context, $idem, $data, $result);
return $result;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceAuditLoggerContract;
use App\Domain\SchoolCore\Attendance\Contracts\StaffAttendanceServiceContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceAuditData;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceRecordResult;
use App\Domain\SchoolCore\Attendance\DTO\RecordStaffAttendanceData;
use App\Domain\SchoolCore\Attendance\Repositories\AttendanceRepository;
use App\Domain\SchoolCore\Attendance\Support\AttendanceTransactionRunner;
use App\Domain\SchoolCore\Context\SchoolContext;
final class StaffAttendanceService implements StaffAttendanceServiceContract
{
public function __construct(
private AttendanceRepository $records,
private AttendanceAuditLoggerContract $audit,
private AttendanceTransactionRunner $transactions,
) {}
public function recordStaffAttendance(SchoolContext $context, RecordStaffAttendanceData $data): AttendanceRecordResult
{
return $this->transactions->run(function () use ($context, $data): AttendanceRecordResult {
$record = $this->records->upsertRecord($context, [
'attendance_session_id' => $data->sessionId,
'attendance_date' => $data->attendanceDate,
'subject_type' => 'staff',
'subject_id' => $data->staffId,
], [
'status' => $data->status,
'source' => $data->source,
'first_seen_at' => $data->recordedAt->format('Y-m-d H:i:s'),
'last_seen_at' => $data->recordedAt->format('Y-m-d H:i:s'),
'recorded_by_user_id' => $context->actorUserId,
'override_reason' => $data->overrideReason,
'metadata' => json_encode($data->metadata, JSON_THROW_ON_ERROR),
]);
$this->audit->log($context, new AttendanceAuditData(
action: 'attendance.staff.recorded',
subjectType: 'staff',
subjectId: $data->staffId,
attendanceSessionId: $data->sessionId,
attendanceRecordId: (int) $record->id,
after: ['status' => $data->status],
reason: $data->reason ?? $data->overrideReason,
source: $data->source,
idempotencyKey: $data->idempotencyKey,
));
return new AttendanceRecordResult((int) $record->id, 'staff', $data->staffId, $data->status, true);
});
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Services;
use App\Domain\SchoolCore\Attendance\Contracts\AttendanceAuditLoggerContract;
use App\Domain\SchoolCore\Attendance\Contracts\StudentAttendanceServiceContract;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceAuditData;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceRecordResult;
use App\Domain\SchoolCore\Attendance\DTO\RecordStudentAttendanceData;
use App\Domain\SchoolCore\Attendance\Repositories\AttendanceRepository;
use App\Domain\SchoolCore\Attendance\Support\AttendanceTransactionRunner;
use App\Domain\SchoolCore\Context\SchoolContext;
final class StudentAttendanceService implements StudentAttendanceServiceContract
{
public function __construct(
private AttendanceRepository $records,
private AttendanceAuditLoggerContract $audit,
private AttendanceTransactionRunner $transactions,
) {}
public function recordStudentAttendance(SchoolContext $context, RecordStudentAttendanceData $data): AttendanceRecordResult
{
return $this->transactions->run(function () use ($context, $data): AttendanceRecordResult {
$record = $this->records->upsertRecord($context, [
'attendance_session_id' => $data->sessionId,
'attendance_date' => $data->attendanceDate,
'subject_type' => 'student',
'subject_id' => $data->studentId,
], [
'status' => $data->status,
'source' => $data->source,
'first_seen_at' => $data->recordedAt->format('Y-m-d H:i:s'),
'last_seen_at' => $data->recordedAt->format('Y-m-d H:i:s'),
'recorded_by_user_id' => $context->actorUserId,
'override_reason' => $data->overrideReason,
'metadata' => json_encode($data->metadata, JSON_THROW_ON_ERROR),
]);
$this->audit->log($context, new AttendanceAuditData(
action: 'attendance.student.recorded',
subjectType: 'student',
subjectId: $data->studentId,
attendanceSessionId: $data->sessionId,
attendanceRecordId: (int) $record->id,
after: ['status' => $data->status],
reason: $data->reason ?? $data->overrideReason,
source: $data->source,
idempotencyKey: $data->idempotencyKey,
));
return new AttendanceRecordResult((int) $record->id, 'student', $data->studentId, $data->status, true);
});
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Support;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeImmutable;
use DateTimeZone;
final class AttendanceClock
{
public function now(SchoolContext $context): DateTimeImmutable
{
return new DateTimeImmutable('now', new DateTimeZone($context->timezone));
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Support;
final readonly class AttendanceIdempotencyKey
{
public function __construct(public string $operation, public string $key)
{
if (trim($operation) === '' || trim($key) === '') {
throw new \InvalidArgumentException('Attendance idempotency operation and key are required.');
}
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Support;
use Illuminate\Support\Facades\DB;
final class AttendanceTransactionRunner
{
public function run(callable $callback): mixed
{
return DB::transaction($callback);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Attendance\Support;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Attendance\DTO\ProcessScanData;
final class ScanFingerprint
{
public static function fromScan(SchoolContext $context, ProcessScanData $data, int $windowSeconds): string
{
$bucket = intdiv($data->scannedAt->getTimestamp(), max(1, $windowSeconds));
return hash('sha256', implode('|', [
$context->schoolId,
mb_strtolower(trim($data->badgeValue)),
$data->stationId ?? '',
$data->scanAction,
$data->scanSource,
$bucket,
]));
}
public static function payloadHash(ProcessScanData $data): string
{
return hash('sha256', json_encode([
'badge_value' => $data->badgeValue,
'station_id' => $data->stationId,
'scan_action' => $data->scanAction,
'scan_source' => $data->scanSource,
'scanned_at' => $data->scannedAt->format(DATE_ATOM),
'device_id' => $data->deviceId,
'client_request_id' => $data->clientRequestId,
'metadata' => $data->metadata,
], JSON_THROW_ON_ERROR));
}
}
@@ -0,0 +1,3 @@
# Authorization
Neutral SchoolCore module boundary. Keep domain-specific vocabulary in extension modules.
@@ -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); }
}

Some files were not shown because too many files have changed in this diff Show More