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,18 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
final class ContextPayload
{
/**
* @return array<string, mixed>
*/
public static function fromContext(SchoolContext $context, ?string $requestId = null): array
{
return $context->auditPayload() + [
'request_id' => $requestId,
];
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
final readonly class SchoolContext
{
public const DOMAIN_STANDARD_SCHOOL = 'standard_school';
public const DOMAIN_ISLAMIC_SUNDAY_SCHOOL = 'islamic_sunday_school';
public const DOMAIN_TRAINING_CENTER = 'training_center';
/**
* @param array<int|string> $actorRoleIds
*/
public function __construct(
public int $schoolId,
public int $actorUserId,
public array $actorRoleIds,
public ?string $schoolYear,
public ?string $term,
public string $timezone,
public string $locale,
public string $currency,
public string $domainProfile,
) {
}
public function isIslamicSundaySchool(): bool
{
return $this->domainProfile === self::DOMAIN_ISLAMIC_SUNDAY_SCHOOL;
}
public function requiresTerm(): bool
{
return in_array($this->domainProfile, [
self::DOMAIN_STANDARD_SCHOOL,
self::DOMAIN_ISLAMIC_SUNDAY_SCHOOL,
self::DOMAIN_TRAINING_CENTER,
], true);
}
/**
* @return array<string, mixed>
*/
public function auditPayload(): array
{
return [
'school_id' => $this->schoolId,
'actor_user_id' => $this->actorUserId,
'actor_role_ids' => $this->actorRoleIds,
'school_year' => $this->schoolYear,
'term' => $this->term,
'timezone' => $this->timezone,
'locale' => $this->locale,
'currency' => $this->currency,
'domain_profile' => $this->domainProfile,
];
}
public function withTerm(?string $term): self
{
return new self(
schoolId: $this->schoolId,
actorUserId: $this->actorUserId,
actorRoleIds: $this->actorRoleIds,
schoolYear: $this->schoolYear,
term: $term,
timezone: $this->timezone,
locale: $this->locale,
currency: $this->currency,
domainProfile: $this->domainProfile,
);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
use RuntimeException;
final class SchoolContextException extends RuntimeException
{
public static function missingAuthenticatedUser(): self
{
return new self('School context cannot be resolved without an authenticated user.', 401);
}
public static function missingSchool(): self
{
return new self('School context cannot be resolved because no school was selected or assigned.', 422);
}
public static function unauthorizedSchoolSwitch(int $schoolId): self
{
return new self("Actor is not allowed to use school context {$schoolId}.", 403);
}
public static function invalid(string $message, int $status = 422): self
{
return new self($message, $status);
}
public static function missingCurrentContext(): self
{
return new self('No current SchoolContext is available. Did ResolveSchoolContext middleware run?', 500);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
final class SchoolContextFactory
{
/**
* @param array<int|string> $actorRoleIds
*/
public function make(
int $schoolId,
int $actorUserId,
array $actorRoleIds,
?string $schoolYear,
?string $term,
?string $timezone = null,
?string $locale = null,
?string $currency = null,
?string $domainProfile = null,
): SchoolContext {
return new SchoolContext(
schoolId: $schoolId,
actorUserId: $actorUserId,
actorRoleIds: array_values(array_unique($actorRoleIds)),
schoolYear: $this->blankToNull($schoolYear),
term: $this->blankToNull($term),
timezone: $timezone ?: config('school_context.defaults.timezone', 'UTC'),
locale: $locale ?: config('school_context.defaults.locale', 'en'),
currency: strtoupper($currency ?: config('school_context.defaults.currency', 'USD')),
domainProfile: $domainProfile ?: config('school_context.defaults.domain_profile', SchoolContext::DOMAIN_STANDARD_SCHOOL),
);
}
private function blankToNull(?string $value): ?string
{
$value = is_string($value) ? trim($value) : $value;
return $value === '' ? null : $value;
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
final class SchoolContextResolver
{
public function __construct(
private readonly SchoolContextFactory $factory,
) {
}
public function resolve(Request $request): SchoolContext
{
$user = $request->user();
if (! $user instanceof Authenticatable) {
throw SchoolContextException::missingAuthenticatedUser();
}
$defaultSchoolId = $this->readIntProperty($user, 'school_id');
$requestedSchoolId = $this->readIntHeader($request, 'X-School-Id');
$schoolId = $requestedSchoolId ?: $defaultSchoolId;
if (! $schoolId) {
throw SchoolContextException::missingSchool();
}
if ($requestedSchoolId && $requestedSchoolId !== $defaultSchoolId && ! $this->canSwitchSchool($user, $requestedSchoolId)) {
throw SchoolContextException::unauthorizedSchoolSwitch($requestedSchoolId);
}
return $this->factory->make(
schoolId: $schoolId,
actorUserId: (int) $user->getAuthIdentifier(),
actorRoleIds: $this->roleIdsFor($user),
schoolYear: $request->headers->get('X-School-Year') ?: $this->legacyConfig('school_year') ?: $this->readNullableStringProperty($user, 'school_year'),
term: $request->headers->get('X-School-Term') ?: $this->legacyConfig('semester') ?: $this->readNullableStringProperty($user, 'semester'),
timezone: $this->schoolValue($schoolId, 'timezone') ?: $this->readNullableStringProperty($user, 'timezone'),
locale: $this->schoolValue($schoolId, 'locale') ?: app()->getLocale(),
currency: $this->schoolValue($schoolId, 'currency'),
domainProfile: $this->domainProfileFor($request, $schoolId),
);
}
private function canSwitchSchool(Authenticatable $user, int $schoolId): bool
{
if (method_exists($user, 'can') && $user->can('switchSchoolContext', $schoolId)) {
return true;
}
if ((bool) ($user->is_platform_admin ?? false)) {
return true;
}
if (method_exists($user, 'schools')) {
return $user->schools()->whereKey($schoolId)->exists();
}
return false;
}
/**
* @return array<int|string>
*/
private function roleIdsFor(Authenticatable $user): array
{
if (method_exists($user, 'roles')) {
return $user->roles()->pluck('id')->all();
}
$roleId = $this->readIntProperty($user, 'role_id');
return $roleId ? [$roleId] : [];
}
private function domainProfileFor(Request $request, int $schoolId): string
{
if (config('school_context.allow_domain_profile_header', false)) {
$headerValue = $request->headers->get('X-Domain-Profile');
if (is_string($headerValue) && $headerValue !== '') {
return $headerValue;
}
}
return $this->schoolValue($schoolId, 'domain_profile')
?: config('school_context.defaults.domain_profile', SchoolContext::DOMAIN_STANDARD_SCHOOL);
}
private function schoolValue(int $schoolId, string $field): ?string
{
$schoolModel = config('school_context.models.school');
if (! is_string($schoolModel) || ! class_exists($schoolModel)) {
return null;
}
$school = $schoolModel::query()->find($schoolId);
$value = $school?->{$field};
return is_string($value) && $value !== '' ? $value : null;
}
private function legacyConfig(string $key): ?string
{
$configurationModel = config('school_context.models.configuration');
if (is_string($configurationModel) && class_exists($configurationModel) && method_exists($configurationModel, 'getConfig')) {
try {
$value = $configurationModel::getConfig($key);
return is_scalar($value) && (string) $value !== '' ? (string) $value : null;
} catch (\Throwable $e) {
Log::warning('Legacy configuration fallback failed while resolving SchoolContext.', [
'key' => $key,
'exception' => $e::class,
]);
}
}
return config("school_context.legacy_fallbacks.{$key}");
}
private function readIntHeader(Request $request, string $header): ?int
{
$value = $request->headers->get($header);
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
}
private function readIntProperty(object $object, string $property): ?int
{
$value = $object->{$property} ?? null;
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
}
private function readNullableStringProperty(object $object, string $property): ?string
{
$value = $object->{$property} ?? null;
return is_scalar($value) && trim((string) $value) !== '' ? trim((string) $value) : null;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
final class SchoolContextStore
{
private ?SchoolContext $context = null;
public function set(SchoolContext $context): void
{
$this->context = $context;
}
public function current(): SchoolContext
{
if (! $this->context instanceof SchoolContext) {
throw SchoolContextException::missingCurrentContext();
}
return $this->context;
}
public function forget(): void
{
$this->context = null;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Domain\SchoolCore\Context;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Log;
final class SchoolContextValidator
{
/** @var array<int, string> */
private array $supportedDomainProfiles;
/** @var array<int, string> */
private array $supportedLocales;
public function __construct()
{
$this->supportedDomainProfiles = config('school_context.supported_domain_profiles', [
SchoolContext::DOMAIN_STANDARD_SCHOOL,
SchoolContext::DOMAIN_ISLAMIC_SUNDAY_SCHOOL,
SchoolContext::DOMAIN_TRAINING_CENTER,
]);
$this->supportedLocales = config('school_context.supported_locales', ['en']);
}
public function assertValid(SchoolContext $context, ?Authenticatable $actor = null): void
{
if ($actor === null) {
throw SchoolContextException::missingAuthenticatedUser();
}
if ($context->schoolId <= 0) {
throw SchoolContextException::invalid('schoolId must be a positive integer.');
}
if ($context->actorUserId <= 0) {
throw SchoolContextException::invalid('actorUserId must be a positive integer.');
}
if (property_exists($actor, 'active') && ! (bool) $actor->active) {
throw SchoolContextException::invalid('Actor is not active.', 403);
}
if (! $this->actorBelongsToSchool($actor, $context->schoolId)) {
throw SchoolContextException::unauthorizedSchoolSwitch($context->schoolId);
}
if (! in_array($context->timezone, timezone_identifiers_list(), true)) {
throw SchoolContextException::invalid("Invalid timezone [{$context->timezone}].");
}
if (! in_array($context->locale, $this->supportedLocales, true)) {
throw SchoolContextException::invalid("Unsupported locale [{$context->locale}].");
}
if (! preg_match('/^[A-Z]{3}$/', $context->currency)) {
throw SchoolContextException::invalid("Invalid ISO currency code [{$context->currency}].");
}
if (! in_array($context->domainProfile, $this->supportedDomainProfiles, true)) {
throw SchoolContextException::invalid("Unsupported domain profile [{$context->domainProfile}].");
}
$this->warnIfAcademicPeriodMissing($context);
}
private function actorBelongsToSchool(Authenticatable $actor, int $schoolId): bool
{
if ((bool) ($actor->is_platform_admin ?? false)) {
return true;
}
if ((int) ($actor->school_id ?? 0) === $schoolId) {
return true;
}
if (method_exists($actor, 'schools')) {
return $actor->schools()->whereKey($schoolId)->exists();
}
return false;
}
private function warnIfAcademicPeriodMissing(SchoolContext $context): void
{
if ($context->requiresTerm() && ($context->schoolYear === null || $context->term === null)) {
Log::warning('SchoolContext resolved without complete academic period during migration.', $context->auditPayload());
}
}
}