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