update project
This commit is contained in:
@@ -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; }
|
||||
+4
@@ -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; }
|
||||
+4
@@ -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
Reference in New Issue
Block a user