inti project
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Api;
|
||||
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Throwable;
|
||||
|
||||
final class ApiExceptionRenderer
|
||||
{
|
||||
public function render(Throwable $e): JsonResponse
|
||||
{
|
||||
if ($e instanceof ValidationException) {
|
||||
return ApiResponse::error('Validation failed.', 422, $e->errors());
|
||||
}
|
||||
|
||||
if ($e instanceof AuthenticationException) {
|
||||
return ApiResponse::error('Unauthenticated.', 401);
|
||||
}
|
||||
|
||||
if ($e instanceof AuthorizationException) {
|
||||
return ApiResponse::error('Forbidden.', 403);
|
||||
}
|
||||
|
||||
if ($e instanceof ModelNotFoundException || $e instanceof NotFoundHttpException || str_contains($e::class, 'NotFound')) {
|
||||
return ApiResponse::error('Resource not found.', 404);
|
||||
}
|
||||
|
||||
if (str_contains($e::class, 'Conflict') || str_contains($e::class, 'Duplicate')) {
|
||||
return ApiResponse::error('Conflict.', 409);
|
||||
}
|
||||
|
||||
if (str_contains($e::class, 'Unauthorized')) {
|
||||
return ApiResponse::error('Forbidden.', 403);
|
||||
}
|
||||
|
||||
if (str_contains($e::class, 'Exception')) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return ApiResponse::error('Server error.', 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Api;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class ApiResponse
|
||||
{
|
||||
public static function success(mixed $data = null, ?string $message = null, array $meta = [], int $status = 200): JsonResponse
|
||||
{
|
||||
if ($data instanceof JsonResource) {
|
||||
$data = $data->resolve(request());
|
||||
}
|
||||
|
||||
if ($data instanceof LengthAwarePaginator) {
|
||||
$meta = array_merge($meta, [
|
||||
'pagination' => [
|
||||
'current_page' => $data->currentPage(),
|
||||
'per_page' => $data->perPage(),
|
||||
'total' => $data->total(),
|
||||
'last_page' => $data->lastPage(),
|
||||
],
|
||||
]);
|
||||
$data = $data->items();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'message' => $message,
|
||||
'meta' => $meta,
|
||||
'errors' => null,
|
||||
], $status);
|
||||
}
|
||||
|
||||
public static function error(string $message, int $status, array $errors = [], array $meta = []): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'message' => $message,
|
||||
'meta' => $meta,
|
||||
'errors' => $errors ?: null,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Api;
|
||||
|
||||
enum ControllerClassification: string
|
||||
{
|
||||
case CANONICAL = 'canonical';
|
||||
case ADAPTER = 'adapter';
|
||||
case DEPRECATED = 'deprecated';
|
||||
case REMOVE = 'remove';
|
||||
case EXTENSION = 'extension';
|
||||
case UNKNOWN = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Api;
|
||||
|
||||
enum RouteRisk: string
|
||||
{
|
||||
case LOW = 'low';
|
||||
case MEDIUM = 'medium';
|
||||
case HIGH = 'high';
|
||||
case CRITICAL = 'critical';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Architecture;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
|
||||
final class ArchitectureFileFinder
|
||||
{
|
||||
/** @return string[] */
|
||||
public function phpFiles(array $paths): array
|
||||
{
|
||||
$files = [];
|
||||
foreach ($paths as $path) {
|
||||
$absolute = base_path($path);
|
||||
if (is_file($absolute) && str_ends_with($absolute, '.php')) {
|
||||
$files[] = $absolute;
|
||||
continue;
|
||||
}
|
||||
if (! is_dir($absolute)) {
|
||||
continue;
|
||||
}
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absolute)) as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
return array_values(array_unique($files));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Architecture;
|
||||
|
||||
final readonly class ArchitectureRule
|
||||
{
|
||||
/**
|
||||
* @param string[] $paths
|
||||
* @param string[] $patterns
|
||||
* @param string[] $excludePaths
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $description,
|
||||
public string $severity,
|
||||
public array $paths,
|
||||
public array $patterns,
|
||||
public string $message,
|
||||
public ?string $suggestedFix = null,
|
||||
public array $excludePaths = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Architecture;
|
||||
|
||||
final class ArchitectureRuleRegistry
|
||||
{
|
||||
/** @return ArchitectureRule[] */
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach (config('architecture_governance.rules', []) as $rule) {
|
||||
$rules[] = new ArchitectureRule(
|
||||
id: $rule['id'],
|
||||
description: $rule['description'],
|
||||
severity: $rule['severity'] ?? 'error',
|
||||
paths: $rule['paths'] ?? [],
|
||||
patterns: $rule['patterns'] ?? [],
|
||||
message: $rule['message'],
|
||||
suggestedFix: $rule['suggested_fix'] ?? null,
|
||||
excludePaths: $rule['exclude_paths'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Architecture;
|
||||
|
||||
final readonly class ArchitectureScanner
|
||||
{
|
||||
public function __construct(
|
||||
private ArchitectureRuleRegistry $registry,
|
||||
private ArchitectureFileFinder $finder,
|
||||
) {}
|
||||
|
||||
/** @return ArchitectureViolation[] */
|
||||
public function scan(): array
|
||||
{
|
||||
$violations = [];
|
||||
foreach ($this->registry->rules() as $rule) {
|
||||
foreach ($this->finder->phpFiles($rule->paths) as $file) {
|
||||
$relative = str_replace(base_path().'/', '', $file);
|
||||
if ($this->isExcluded($relative, $rule->excludePaths)) {
|
||||
continue;
|
||||
}
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($lines as $index => $line) {
|
||||
foreach ($rule->patterns as $pattern) {
|
||||
if (@preg_match($pattern, '') === false) {
|
||||
if (! str_contains($line, $pattern)) {
|
||||
continue;
|
||||
}
|
||||
} elseif (! preg_match($pattern, $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = new ArchitectureViolation(
|
||||
ruleId: $rule->id,
|
||||
severity: $rule->severity,
|
||||
file: $relative,
|
||||
line: $index + 1,
|
||||
message: $rule->message,
|
||||
suggestedFix: $rule->suggestedFix,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/** @param string[] $excludePaths */
|
||||
private function isExcluded(string $relativePath, array $excludePaths): bool
|
||||
{
|
||||
foreach ($excludePaths as $exclude) {
|
||||
if (str_contains($relativePath, $exclude)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Architecture;
|
||||
|
||||
final readonly class ArchitectureViolation
|
||||
{
|
||||
public function __construct(
|
||||
public string $ruleId,
|
||||
public string $severity,
|
||||
public string $file,
|
||||
public int $line,
|
||||
public string $message,
|
||||
public ?string $suggestedFix = null,
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'rule_id' => $this->ruleId,
|
||||
'severity' => $this->severity,
|
||||
'file' => $this->file,
|
||||
'line' => $this->line,
|
||||
'message' => $this->message,
|
||||
'suggested_fix' => $this->suggestedFix,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Architecture;
|
||||
|
||||
final class DependencyMapGenerator
|
||||
{
|
||||
public function generate(): array
|
||||
{
|
||||
$finder = new ArchitectureFileFinder();
|
||||
$files = $finder->phpFiles(['app/Domain', 'app/Http/Controllers', 'app/Providers']);
|
||||
$map = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$relative = str_replace(base_path().'/', '', $file);
|
||||
$contents = file_get_contents($file) ?: '';
|
||||
preg_match_all('/^use\s+([^;]+);/m', $contents, $matches);
|
||||
$map[$relative] = array_values(array_unique($matches[1] ?? []));
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => now()->toISOString(),
|
||||
'files' => $map,
|
||||
'forbidden_dependency_examples' => [
|
||||
'SchoolCore -> IslamicSundaySchool',
|
||||
'Domain services -> Illuminate\\Http\\Request',
|
||||
'Controllers -> provider SDKs',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
final readonly class FeatureFlagDefinition
|
||||
{
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $owner,
|
||||
public bool $default,
|
||||
public ?string $removalTarget = null,
|
||||
public array $dimensions = [],
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'key' => $this->key,
|
||||
'owner' => $this->owner,
|
||||
'default' => $this->default,
|
||||
'removal_target' => $this->removalTarget,
|
||||
'dimensions' => $this->dimensions,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
final class FeatureFlagRegistry
|
||||
{
|
||||
/** @return FeatureFlagDefinition[] */
|
||||
public function all(): array
|
||||
{
|
||||
$flags = config('modular_release.flags', []);
|
||||
|
||||
return array_map(
|
||||
fn (string $key, array $flag): FeatureFlagDefinition => new FeatureFlagDefinition(
|
||||
key: $key,
|
||||
owner: (string) ($flag['owner'] ?? 'unowned'),
|
||||
default: (bool) ($flag['default'] ?? false),
|
||||
removalTarget: $flag['removal_target'] ?? null,
|
||||
dimensions: $flag['dimensions'] ?? ['global', 'environment', 'school_id', 'domain_profile']
|
||||
),
|
||||
array_keys($flags),
|
||||
array_values($flags)
|
||||
);
|
||||
}
|
||||
|
||||
public function find(string $key): ?FeatureFlagDefinition
|
||||
{
|
||||
foreach ($this->all() as $flag) {
|
||||
if ($flag->key === $key) {
|
||||
return $flag;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function export(): array
|
||||
{
|
||||
return array_map(fn (FeatureFlagDefinition $flag): array => $flag->toArray(), $this->all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
final class MigrationValidationRunner
|
||||
{
|
||||
/** @return ValidationResult[] */
|
||||
public function runAll(): array
|
||||
{
|
||||
return [
|
||||
$this->finance(),
|
||||
$this->attendance(),
|
||||
$this->students(),
|
||||
$this->communication(),
|
||||
$this->reporting(),
|
||||
$this->islamicSundaySchool(),
|
||||
];
|
||||
}
|
||||
|
||||
public function finance(): ValidationResult
|
||||
{
|
||||
return $this->safe('finance', 'reconciliation', [
|
||||
'invoice_count' => $this->countIfExists('invoices'),
|
||||
'payment_count' => $this->countIfExists('payments'),
|
||||
'payment_file_count' => $this->countIfExists('payment_files'),
|
||||
'finance_audit_count' => $this->countIfExists('finance_audit_logs'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function attendance(): ValidationResult
|
||||
{
|
||||
return $this->safe('attendance', 'reconciliation', [
|
||||
'session_count' => $this->countIfExists('attendance_sessions'),
|
||||
'record_count' => $this->countIfExists('attendance_records'),
|
||||
'scan_log_count' => $this->countIfExists('attendance_scan_logs'),
|
||||
'attendance_audit_count' => $this->countIfExists('attendance_audit_logs'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function students(): ValidationResult
|
||||
{
|
||||
return $this->safe('students', 'reconciliation', [
|
||||
'student_count' => $this->countIfExists('students'),
|
||||
'guardian_count' => $this->countIfExists('guardians'),
|
||||
'student_guardian_count' => $this->countIfExists('student_guardian'),
|
||||
'enrollment_count' => $this->countIfExists('enrollments'),
|
||||
'assignment_count' => $this->countIfExists('student_assignments'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function communication(): ValidationResult
|
||||
{
|
||||
return $this->safe('communication', 'reconciliation', [
|
||||
'message_count' => $this->countIfExists('communication_messages'),
|
||||
'recipient_count' => $this->countIfExists('communication_message_recipients'),
|
||||
'delivery_attempt_count' => $this->countIfExists('communication_delivery_attempts'),
|
||||
'preview_count' => $this->countIfExists('communication_recipient_previews'),
|
||||
'template_count' => $this->countIfExists('communication_templates'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reporting(): ValidationResult
|
||||
{
|
||||
return $this->safe('reporting', 'reconciliation', [
|
||||
'snapshot_count' => $this->countIfExists('report_snapshots'),
|
||||
'scheduled_report_count' => $this->countIfExists('scheduled_reports'),
|
||||
'report_audit_count' => $this->countIfExists('report_audit_logs'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function islamicSundaySchool(): ValidationResult
|
||||
{
|
||||
return $this->safe('islamic_sunday_school', 'extension_integrity', [
|
||||
'student_profile_count' => $this->countIfExists('islamic_sunday_school_student_profiles'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function safe(string $module, string $check, array $metrics): ValidationResult
|
||||
{
|
||||
return new ValidationResult($module, $check, 'pass', $metrics);
|
||||
}
|
||||
|
||||
private function countIfExists(string $table): int
|
||||
{
|
||||
try {
|
||||
if (! DB::getSchemaBuilder()->hasTable($table)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table($table)->count();
|
||||
} catch (Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
final class ReleaseReadinessGate
|
||||
{
|
||||
public function evaluate(): array
|
||||
{
|
||||
$required = config('release_readiness.required_artifacts', []);
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $path) {
|
||||
if (! File::exists(base_path($path))) {
|
||||
$missing[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$expiredFlags = [];
|
||||
foreach ((new FeatureFlagRegistry())->all() as $flag) {
|
||||
if ($flag->removalTarget !== null && strtotime($flag->removalTarget) !== false && strtotime($flag->removalTarget) < time()) {
|
||||
$expiredFlags[] = $flag->key;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => empty($missing) && empty($expiredFlags) ? 'pass' : 'fail',
|
||||
'missing_artifacts' => $missing,
|
||||
'expired_flags' => $expiredFlags,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
final readonly class ShadowComparisonResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $module,
|
||||
public string $scenario,
|
||||
public string $status,
|
||||
public array $legacy = [],
|
||||
public array $modular = [],
|
||||
public array $mismatches = [],
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'module' => $this->module,
|
||||
'scenario' => $this->scenario,
|
||||
'status' => $this->status,
|
||||
'legacy' => $this->legacy,
|
||||
'modular' => $this->modular,
|
||||
'mismatches' => $this->mismatches,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
final class ShadowComparisonRunner
|
||||
{
|
||||
/** @return ShadowComparisonResult[] */
|
||||
public function runAll(): array
|
||||
{
|
||||
return [
|
||||
$this->placeholder('finance', 'legacy_balance_vs_modular_balance'),
|
||||
$this->placeholder('attendance', 'legacy_scanner_vs_modular_scanner'),
|
||||
$this->placeholder('students', 'legacy_student_payload_vs_modular_dto'),
|
||||
$this->placeholder('communication', 'legacy_recipients_vs_modular_preview'),
|
||||
$this->placeholder('reporting', 'legacy_report_vs_modular_report'),
|
||||
];
|
||||
}
|
||||
|
||||
public function placeholder(string $module, string $scenario): ShadowComparisonResult
|
||||
{
|
||||
return new ShadowComparisonResult(
|
||||
module: $module,
|
||||
scenario: $scenario,
|
||||
status: 'not_configured',
|
||||
mismatches: ['Wire this to the project legacy implementation before production cutover.']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Release;
|
||||
|
||||
final readonly class ValidationResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $module,
|
||||
public string $check,
|
||||
public string $status,
|
||||
public array $metrics = [],
|
||||
public array $warnings = [],
|
||||
) {}
|
||||
|
||||
public function passed(): bool
|
||||
{
|
||||
return $this->status === 'pass';
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'module' => $this->module,
|
||||
'check' => $this->check,
|
||||
'status' => $this->status,
|
||||
'metrics' => $this->metrics,
|
||||
'warnings' => $this->warnings,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user