diff --git a/app/Http/Controllers/Api/Integration/SchoolWorkflowController.php b/app/Http/Controllers/Api/Integration/SchoolWorkflowController.php new file mode 100644 index 0000000..803d953 --- /dev/null +++ b/app/Http/Controllers/Api/Integration/SchoolWorkflowController.php @@ -0,0 +1,457 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string', 'max:64'], + 'timezone' => ['nullable', 'string', 'max:64'], + 'locale' => ['nullable', 'string', 'max:16'], + 'currency' => ['nullable', 'string', 'size:3'], + 'domain_profile' => ['nullable', 'string', 'max:64'], + ]); + + $id = (int) DB::table('schools')->insertGetId([ + 'name' => $data['name'], + 'code' => $data['code'] ?? null, + 'timezone' => $data['timezone'] ?? 'UTC', + 'locale' => $data['locale'] ?? 'en', + 'currency' => strtoupper($data['currency'] ?? 'USD'), + 'domain_profile' => $data['domain_profile'] ?? 'standard_school', + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $this->ok($this->schoolPayload($id), Response::HTTP_CREATED, 'School created.'); + } + + public function showSchool(int $school): JsonResponse + { + return $this->ok($this->schoolPayload($school)); + } + + public function storeStudent(Request $request, int $school): JsonResponse + { + $this->assertSchoolExists($school); + + $data = $request->validate([ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'student_number' => ['nullable', 'string', 'max:128'], + 'school_id_number' => ['nullable', 'string', 'max:128'], + 'grade_level' => ['nullable', 'string', 'max:64'], + 'status' => ['nullable', 'string', 'max:64'], + ]); + + $studentNumber = $data['student_number'] ?? $data['school_id_number'] ?? null; + + $id = (int) DB::table('students')->insertGetId([ + 'school_id' => $school, + 'school_id_number' => $studentNumber, + 'student_identifier' => $studentNumber, + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'status' => $data['status'] ?? 'active', + 'profile_metadata' => json_encode(['grade_level' => $data['grade_level'] ?? null], JSON_THROW_ON_ERROR), + 'metadata' => json_encode(['grade_level' => $data['grade_level'] ?? null], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $this->ok($this->studentPayload($school, $id), Response::HTTP_CREATED, 'Student created.'); + } + + public function showStudent(int $school, int $student): JsonResponse + { + return $this->ok($this->studentPayload($school, $student)); + } + + public function storeAttendanceScan(Request $request, int $school): JsonResponse + { + $this->assertSchoolExists($school); + + $data = $request->validate([ + 'student_number' => ['required_without:student_id', 'string', 'max:128'], + 'student_id' => ['required_without:student_number', 'integer'], + 'scanned_at' => ['nullable', 'date'], + 'source' => ['nullable', 'string', 'max:64'], + 'station_id' => ['nullable', 'string', 'max:128'], + ]); + + $student = $this->findStudentByScanInput($school, $data); + $scannedAt = isset($data['scanned_at']) ? Carbon::parse($data['scanned_at']) : now(); + $sessionId = $this->resolveDailySession($school, $scannedAt); + + $existing = DB::table('attendance_records') + ->where('school_id', $school) + ->where('attendance_session_id', $sessionId) + ->where('subject_type', 'student') + ->where('subject_id', $student->id) + ->first(); + + if ($existing) { + DB::table('attendance_records')->where('id', $existing->id)->update([ + 'last_seen_at' => $scannedAt, + 'updated_at' => now(), + ]); + $recordId = (int) $existing->id; + $statusResult = 'duplicate'; + } else { + $recordId = (int) DB::table('attendance_records')->insertGetId([ + 'school_id' => $school, + 'attendance_session_id' => $sessionId, + 'attendance_date' => $scannedAt->toDateString(), + 'subject_type' => 'student', + 'subject_id' => $student->id, + 'status' => 'present', + 'source' => $data['source'] ?? 'scan', + 'first_seen_at' => $scannedAt, + 'last_seen_at' => $scannedAt, + 'metadata' => json_encode(['student_number' => $student->school_id_number], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $statusResult = 'recorded'; + } + + $scanId = (int) DB::table('attendance_scan_logs')->insertGetId([ + 'school_id' => $school, + 'attendance_record_id' => $recordId, + 'subject_type' => 'student', + 'subject_id' => $student->id, + 'station_id' => $data['station_id'] ?? null, + 'scan_action' => 'check_in', + 'scan_source' => $data['source'] ?? 'scan', + 'scanned_at' => $scannedAt, + 'status_result' => $statusResult, + 'raw_payload_json' => json_encode($data, JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $this->ok([ + 'scan_id' => $scanId, + 'attendance_record_id' => $recordId, + 'student_id' => (int) $student->id, + 'status' => $statusResult, + ], $statusResult === 'recorded' ? Response::HTTP_CREATED : Response::HTTP_OK, 'Attendance scan processed.'); + } + + public function previewCommunication(Request $request, int $school): JsonResponse + { + $this->assertSchoolExists($school); + + $data = $request->validate([ + 'template_key' => ['required', 'string', 'max:128'], + 'student_id' => ['required', 'integer'], + 'channel' => ['nullable', 'string', 'max:32'], + ]); + + $student = $this->findStudent($school, (int) $data['student_id']); + $template = $this->findTemplate($school, $data['template_key'], $data['channel'] ?? null); + + return $this->ok([ + 'template_key' => $template->template_key, + 'subject' => $this->renderTemplate($template->subject, $student), + 'body' => $this->renderTemplate($template->body, $student), + 'student_id' => (int) $student->id, + ]); + } + + public function sendCommunication(Request $request, int $school): JsonResponse + { + $this->assertSchoolExists($school); + + $data = $request->validate([ + 'template_key' => ['required', 'string', 'max:128'], + 'student_id' => ['required', 'integer'], + 'channel' => ['required', 'string', 'max:32'], + ]); + + $student = $this->findStudent($school, (int) $data['student_id']); + $template = $this->findTemplate($school, $data['template_key'], $data['channel']); + + $messageId = (int) DB::table('communication_messages')->insertGetId([ + 'school_id' => $school, + 'actor_user_id' => null, + 'channel' => $data['channel'], + 'message_category' => $template->message_category ?? 'general', + 'priority' => 'normal', + 'subject' => $this->renderTemplate($template->subject, $student), + 'body_snapshot' => $this->renderTemplate($template->body, $student), + 'template_id' => $template->id, + 'template_version' => $template->version ?? '1', + 'status' => 'queued', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $recipientId = (int) DB::table('communication_message_recipients')->insertGetId([ + 'message_id' => $messageId, + 'school_id' => $school, + 'recipient_type' => 'student', + 'recipient_id' => $student->id, + 'channel' => $data['channel'], + 'status' => 'queued', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $this->ok([ + 'message_id' => $messageId, + 'recipient_id' => $recipientId, + 'status' => 'queued', + ], Response::HTTP_ACCEPTED, 'Communication queued.'); + } + + public function uploadPaymentFile(Request $request, int $school): JsonResponse + { + $this->assertSchoolExists($school); + + $request->validate(['file' => ['required', 'file']]); + + $file = $request->file('file'); + $rows = array_map('str_getcsv', file($file->getRealPath()) ?: []); + $headers = array_map(static fn ($value): string => trim((string) $value), $rows[0] ?? []); + $required = ['student_number', 'amount', 'paid_at', 'reference']; + + if (array_diff($required, $headers) !== []) { + return $this->fail('Invalid payment file.', Response::HTTP_UNPROCESSABLE_ENTITY, [ + 'file' => ['CSV must include student_number, amount, paid_at, and reference columns.'], + ]); + } + + $imported = []; + + try { + return DB::transaction(function () use ($school, $headers, $rows, &$imported): JsonResponse { + foreach (array_slice($rows, 1) as $row) { + if ($row === [null] || $row === false || count(array_filter($row, static fn ($value) => $value !== null && $value !== '')) === 0) { + continue; + } + + $record = array_combine($headers, array_pad($row, count($headers), null)); + if (! $record || empty($record['student_number']) || ! is_numeric($record['amount']) || empty($record['paid_at']) || empty($record['reference'])) { + throw new \InvalidArgumentException('Invalid payment row.'); + } + + $student = DB::table('students') + ->where('school_id', $school) + ->where('school_id_number', $record['student_number']) + ->first(); + + if (! $student) { + throw new \InvalidArgumentException('Payment row references an unknown student.'); + } + + $paymentId = (int) DB::table('payments')->insertGetId([ + 'school_id' => $school, + 'student_id' => $student->id, + 'amount_minor' => (int) round(((float) $record['amount']) * 100), + 'currency' => 'USD', + 'payment_method' => 'file_import', + 'payment_date' => $record['paid_at'], + 'reference_number' => $record['reference'], + 'status' => 'recorded', + 'metadata' => json_encode(['source' => 'payment_file'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $imported[] = $paymentId; + } + + return $this->ok([ + 'imported_count' => count($imported), + 'payment_ids' => $imported, + ], Response::HTTP_CREATED, 'Payment file imported.'); + }); + } catch (\InvalidArgumentException $exception) { + return $this->fail('Invalid payment file.', Response::HTTP_UNPROCESSABLE_ENTITY, [ + 'file' => [$exception->getMessage()], + ]); + } + } + + public function dailyReport(Request $request, int $school): JsonResponse + { + $this->assertSchoolExists($school); + + $date = (string) $request->query('date', now()->toDateString()); + + return $this->ok([ + 'date' => $date, + 'attendance' => [ + 'present_count' => DB::table('attendance_records') + ->where('school_id', $school) + ->where('attendance_date', $date) + ->where('status', 'present') + ->count(), + ], + 'finance' => [ + 'total_payments' => (int) DB::table('payments') + ->where('school_id', $school) + ->where('payment_date', $date) + ->where('status', '!=', 'reversed') + ->sum('amount_minor'), + ], + ]); + } + + private function schoolPayload(int $school): array + { + $row = DB::table('schools')->where('id', $school)->first(); + + abort_if(! $row, Response::HTTP_NOT_FOUND, 'School not found.'); + + return [ + 'id' => (int) $row->id, + 'name' => $row->name, + 'code' => $row->code ?? null, + 'timezone' => $row->timezone, + 'locale' => $row->locale, + 'currency' => $row->currency, + ]; + } + + private function studentPayload(int $school, int $student): array + { + $row = $this->findStudent($school, $student); + $metadata = json_decode((string) ($row->profile_metadata ?? $row->metadata ?? '{}'), true) ?: []; + + return [ + 'id' => (int) $row->id, + 'school_id' => (int) $row->school_id, + 'first_name' => $row->first_name, + 'last_name' => $row->last_name, + 'student_number' => $row->school_id_number ?? $row->student_identifier, + 'grade_level' => $metadata['grade_level'] ?? null, + 'status' => $row->status, + ]; + } + + private function assertSchoolExists(int $school): void + { + abort_if(! DB::table('schools')->where('id', $school)->exists(), Response::HTTP_NOT_FOUND, 'School not found.'); + } + + private function findStudent(int $school, int $student): object + { + $row = DB::table('students')->where('school_id', $school)->where('id', $student)->first(); + abort_if(! $row, Response::HTTP_NOT_FOUND, 'Student not found.'); + + return $row; + } + + private function findStudentByScanInput(int $school, array $data): object + { + $query = DB::table('students')->where('school_id', $school); + + if (isset($data['student_id'])) { + $query->where('id', $data['student_id']); + } else { + $query->where('school_id_number', $data['student_number']); + } + + $row = $query->first(); + abort_if(! $row, Response::HTTP_NOT_FOUND, 'Student not found.'); + + return $row; + } + + private function resolveDailySession(int $school, \DateTimeInterface $scannedAt): int + { + $date = $scannedAt->format('Y-m-d'); + $existing = DB::table('attendance_sessions') + ->where('school_id', $school) + ->whereDate('starts_at', $date) + ->where('session_type', 'daily') + ->first(); + + if ($existing) { + return (int) $existing->id; + } + + return (int) DB::table('attendance_sessions')->insertGetId([ + 'school_id' => $school, + 'name' => 'Daily attendance '.$date, + 'session_type' => 'daily', + 'starts_at' => $date.' 00:00:00', + 'ends_at' => $date.' 23:59:59', + 'timezone' => 'UTC', + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function findTemplate(int $school, string $templateKey, ?string $channel): object + { + $query = DB::table('communication_templates') + ->where(function ($query) use ($school): void { + $query->where('school_id', $school)->orWhereNull('school_id'); + }) + ->where('template_key', $templateKey) + ->where('status', '!=', 'archived') + ->orderByDesc('school_id') + ->orderByDesc('version'); + + if ($channel !== null) { + $query->where('channel', $channel); + } + + $row = $query->first(); + abort_if(! $row, Response::HTTP_NOT_FOUND, 'Communication template not found.'); + + return $row; + } + + private function renderTemplate(?string $template, object $student): ?string + { + if ($template === null) { + return null; + } + + return Str::of($template) + ->replace('{{ student.first_name }}', (string) $student->first_name) + ->replace('{{student.first_name}}', (string) $student->first_name) + ->replace('{{ student.last_name }}', (string) $student->last_name) + ->replace('{{student.last_name}}', (string) $student->last_name) + ->toString(); + } + + private function ok(array $data, int $status = Response::HTTP_OK, ?string $message = null): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => $data, + 'message' => $message, + 'errors' => null, + ], $status); + } + + private function fail(string $message, int $status, array $errors): JsonResponse + { + return response()->json([ + 'success' => false, + 'data' => null, + 'message' => $message, + 'errors' => $errors, + ], $status); + } +} diff --git a/app/Http/Middleware/EnsureSchoolAccess.php b/app/Http/Middleware/EnsureSchoolAccess.php new file mode 100644 index 0000000..ec56717 --- /dev/null +++ b/app/Http/Middleware/EnsureSchoolAccess.php @@ -0,0 +1,76 @@ +user(); + + if (! $user) { + abort(401); + } + + $schoolId = $this->schoolIdFromRoute($request); + + if (! $schoolId) { + abort(404); + } + + if (! $this->userCanAccessSchool((int) $user->getAuthIdentifier(), $schoolId)) { + abort(404); + } + + return $next($request); + } + + private function schoolIdFromRoute(Request $request): ?int + { + $school = $request->route('school'); + + if (is_numeric($school)) { + return (int) $school; + } + + if (is_object($school) && isset($school->id)) { + return (int) $school->id; + } + + return null; + } + + private function userCanAccessSchool(int $userId, int $schoolId): bool + { + if (Schema::hasTable('school_user')) { + return DB::table('school_user') + ->where('user_id', $userId) + ->where('school_id', $schoolId) + ->exists(); + } + + if (Schema::hasTable('school_users')) { + return DB::table('school_users') + ->where('user_id', $userId) + ->where('school_id', $schoolId) + ->exists(); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'school_id')) { + return DB::table('users') + ->where('id', $userId) + ->where('school_id', $schoolId) + ->exists(); + } + + return false; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index 4b327d2..0e5284a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use App\Http\Middleware\EnsureSchoolAccess; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -13,7 +14,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'school.access' => EnsureSchoolAccess::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_05_29_000001_create_base_school_tables_for_scaffold.php b/database/migrations/2026_05_29_000001_create_base_school_tables_for_scaffold.php index 07c3e78..4d8144d 100644 --- a/database/migrations/2026_05_29_000001_create_base_school_tables_for_scaffold.php +++ b/database/migrations/2026_05_29_000001_create_base_school_tables_for_scaffold.php @@ -12,6 +12,7 @@ return new class extends Migration Schema::create('schools', function (Blueprint $table) { $table->id(); $table->string('name')->default('Default School'); + $table->string('code')->nullable()->unique(); $table->string('domain_profile')->default('standard_school'); $table->string('timezone')->default('UTC'); $table->string('locale')->default('en'); diff --git a/database/migrations/2026_05_29_000003_create_finance_audit_and_payment_file_tables.php b/database/migrations/2026_05_29_000003_create_finance_audit_and_payment_file_tables.php index 2ffc1b1..f6a6cb0 100644 --- a/database/migrations/2026_05_29_000003_create_finance_audit_and_payment_file_tables.php +++ b/database/migrations/2026_05_29_000003_create_finance_audit_and_payment_file_tables.php @@ -7,6 +7,75 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { + if (! Schema::hasTable('invoices')) { + Schema::create('invoices', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('school_id')->index(); + $table->unsignedBigInteger('student_id')->nullable()->index(); + $table->unsignedBigInteger('guardian_id')->nullable()->index(); + $table->string('currency', 3)->default('USD'); + $table->integer('total_amount_minor')->default(0); + $table->integer('paid_amount_minor')->default(0); + $table->integer('balance_amount_minor')->default(0); + $table->string('status')->default('draft')->index(); + $table->date('due_date')->nullable(); + $table->text('notes')->nullable(); + $table->text('void_reason')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('invoice_items')) { + Schema::create('invoice_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('school_id')->index(); + $table->unsignedBigInteger('invoice_id')->index(); + $table->string('description'); + $table->string('type')->default('charge'); + $table->integer('amount_minor'); + $table->string('currency', 3)->default('USD'); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('payments')) { + Schema::create('payments', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('school_id')->index(); + $table->unsignedBigInteger('invoice_id')->nullable()->index(); + $table->unsignedBigInteger('student_id')->nullable()->index(); + $table->unsignedBigInteger('guardian_id')->nullable()->index(); + $table->integer('amount_minor'); + $table->string('currency', 3)->default('USD'); + $table->string('payment_method')->default('external'); + $table->date('payment_date')->nullable()->index(); + $table->string('reference_number')->nullable()->index(); + $table->text('notes')->nullable(); + $table->string('status')->default('recorded')->index(); + $table->text('reverse_reason')->nullable(); + $table->string('idempotency_key')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['school_id', 'reference_number'], 'payments_school_reference_unique'); + }); + } + + if (! Schema::hasTable('payment_refunds')) { + Schema::create('payment_refunds', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('school_id')->index(); + $table->unsignedBigInteger('invoice_id')->nullable()->index(); + $table->unsignedBigInteger('payment_id')->index(); + $table->integer('amount_minor'); + $table->string('currency', 3)->default('USD'); + $table->text('reason')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + Schema::create('finance_audit_logs', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('school_id')->index(); $table->string('academic_year_id')->nullable()->index(); $table->string('term_id')->nullable()->index(); $table->unsignedBigInteger('actor_user_id')->index(); $table->json('actor_role_snapshot')->nullable(); $table->string('action')->index(); $table->string('entity_type')->index(); $table->string('entity_id')->index(); $table->json('before_json')->nullable(); $table->json('after_json')->nullable(); $table->string('reason', 1000)->nullable(); $table->string('request_id')->nullable()->index(); $table->string('ip_address')->nullable(); $table->text('user_agent')->nullable(); $table->string('idempotency_key')->nullable()->index(); $table->timestamps(); $table->index(['entity_type','entity_id']); }); @@ -19,6 +88,6 @@ return new class extends Migration { } public function down(): void { - Schema::dropIfExists('finance_idempotency_keys'); Schema::dropIfExists('payment_files'); Schema::dropIfExists('finance_audit_logs'); + Schema::dropIfExists('finance_idempotency_keys'); Schema::dropIfExists('payment_files'); Schema::dropIfExists('finance_audit_logs'); Schema::dropIfExists('payment_refunds'); Schema::dropIfExists('payments'); Schema::dropIfExists('invoice_items'); Schema::dropIfExists('invoices'); } }; diff --git a/database/migrations/2026_05_29_000004_add_modular_finance_indexes.php b/database/migrations/2026_05_29_000004_add_modular_finance_indexes.php index f2f9b60..1a0e115 100644 --- a/database/migrations/2026_05_29_000004_add_modular_finance_indexes.php +++ b/database/migrations/2026_05_29_000004_add_modular_finance_indexes.php @@ -50,6 +50,18 @@ return new class extends Migration private function indexExists(string $table, string $indexName): bool { + if (DB::getDriverName() === 'sqlite') { + $indexes = DB::select('PRAGMA index_list('.$table.')'); + + foreach ($indexes as $index) { + if (($index->name ?? null) === $indexName) { + return true; + } + } + + return false; + } + $database = DB::getDatabaseName(); $result = DB::selectOne( diff --git a/database/migrations/2026_05_29_000008_create_student_enrollment_assignment_audit_tables.php b/database/migrations/2026_05_29_000008_create_student_enrollment_assignment_audit_tables.php index 611008c..16e7666 100644 --- a/database/migrations/2026_05_29_000008_create_student_enrollment_assignment_audit_tables.php +++ b/database/migrations/2026_05_29_000008_create_student_enrollment_assignment_audit_tables.php @@ -22,6 +22,18 @@ return new class extends Migration { public function up(): void private function indexExists(string $table, string $indexName): bool { + if (DB::getDriverName() === 'sqlite') { + $indexes = DB::select('PRAGMA index_list('.$table.')'); + + foreach ($indexes as $index) { + if (($index->name ?? null) === $indexName) { + return true; + } + } + + return false; + } + $database = DB::getDatabaseName(); $result = DB::selectOne( diff --git a/database/migrations/2026_05_29_000011_create_communication_delivery_template_preference_tables.php b/database/migrations/2026_05_29_000011_create_communication_delivery_template_preference_tables.php index ed2caeb..f0fb621 100644 --- a/database/migrations/2026_05_29_000011_create_communication_delivery_template_preference_tables.php +++ b/database/migrations/2026_05_29_000011_create_communication_delivery_template_preference_tables.php @@ -48,11 +48,11 @@ return new class extends Migration $table->unsignedBigInteger('school_id'); - $table->string('recipient_type', 64); + $table->string('recipient_type', 32); $table->unsignedBigInteger('recipient_id'); $table->string('channel', 32); - $table->string('message_category', 64)->default('general'); + $table->string('message_category', 32)->default('general'); $table->boolean('opted_in')->default(true); $table->boolean('quiet_hours_enabled')->default(false); @@ -89,7 +89,7 @@ return new class extends Migration $table->string('status', 32)->default('draft'); $table->string('channel', 32); - $table->string('message_category', 64)->default('general'); + $table->string('message_category', 32)->default('general'); $table->string('language', 16)->default('en'); $table->string('subject', 191)->nullable(); diff --git a/routes/api.php b/routes/api.php index c41f2cd..34e9020 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,26 @@ group(function (): void { + Route::get('/schools/{school}', [SchoolWorkflowController::class, 'showSchool']); + + Route::post('/schools/{school}/students', [SchoolWorkflowController::class, 'storeStudent']); + Route::get('/schools/{school}/students/{student}', [SchoolWorkflowController::class, 'showStudent']); + + Route::post('/schools/{school}/attendance/scans', [SchoolWorkflowController::class, 'storeAttendanceScan']); + + Route::post('/schools/{school}/communications/preview', [SchoolWorkflowController::class, 'previewCommunication']); + Route::post('/schools/{school}/communications/send', [SchoolWorkflowController::class, 'sendCommunication']); + + Route::post('/schools/{school}/finance/payment-files', [SchoolWorkflowController::class, 'storePaymentFile']); + + Route::get('/schools/{school}/reports/daily', [SchoolWorkflowController::class, 'dailyReport']); +}); + Route::get('/health', function () { return response()->json([ 'success' => true, diff --git a/tests/Feature/Integration/AuthTenantIsolationIntegrationTest.php b/tests/Feature/Integration/AuthTenantIsolationIntegrationTest.php new file mode 100644 index 0000000..3ca221a --- /dev/null +++ b/tests/Feature/Integration/AuthTenantIsolationIntegrationTest.php @@ -0,0 +1,333 @@ +createSchool('Own School', 'OWN'); + $user = $this->createActorForSchool($school); + + $this->actingAs($user); + + $this->getJson("/api/schools/{$school}") + ->assertOk(); + + $studentResponse = $this->postJson("/api/schools/{$school}/students", [ + 'first_name' => 'Maya', + 'last_name' => 'Patel', + 'student_number' => 'OWN-1001', + 'grade_level' => '7', + ]); + + $studentResponse->assertCreated(); + + $studentId = $studentResponse->json('data.id'); + + $this->getJson("/api/schools/{$school}/students/{$studentId}") + ->assertOk() + ->assertJsonPath('data.student_number', 'OWN-1001'); + + $this->postJson("/api/schools/{$school}/attendance/scans", [ + 'student_number' => 'OWN-1001', + 'scanned_at' => '2026-05-29T08:15:00Z', + 'source' => 'kiosk', + ])->assertCreated(); + + $this->createTemplate( + $school, + 'attendance_absence', + 'email', + 'Hello, {{ student.first_name }} was marked absent.' + ); + + $this->postJson("/api/schools/{$school}/communications/preview", [ + 'template_key' => 'attendance_absence', + 'student_id' => $studentId, + 'channel' => 'email', + ])->assertOk(); + + $this->postJson("/api/schools/{$school}/communications/send", [ + 'template_key' => 'attendance_absence', + 'student_id' => $studentId, + 'channel' => 'email', + ])->assertAccepted(); + + $file = UploadedFile::fake()->createWithContent( + 'payments.csv', + "student_number,amount,paid_at,reference\nOWN-1001,125.00,2026-05-29,PAY-OWN-001\n" + ); + + $this->postJson("/api/schools/{$school}/finance/payment-files", [ + 'file' => $file, + ])->assertCreated(); + + $this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29") + ->assertOk(); + } + + public function test_user_cannot_access_another_schools_data(): void + { + $schoolA = $this->createSchool('School A', 'S-A'); + $schoolB = $this->createSchool('School B', 'S-B'); + + $userA = $this->createActorForSchool($schoolA); + + $studentB = $this->createStudent($schoolB, 'OTHER-2001', 'Other'); + + $this->createTemplate( + $schoolB, + 'attendance_absence', + 'email', + 'Hello, {{ student.first_name }} was marked absent.' + ); + + $this->actingAs($userA); + + $this->assertTenantBlocked( + $this->getJson("/api/schools/{$schoolB}") + ); + + $this->assertTenantBlocked( + $this->postJson("/api/schools/{$schoolB}/students", [ + 'first_name' => 'Intruder', + 'last_name' => 'Student', + 'student_number' => 'BAD-1001', + 'grade_level' => '8', + ]) + ); + + $this->assertTenantBlocked( + $this->getJson("/api/schools/{$schoolB}/students/{$studentB}") + ); + + $this->assertTenantBlocked( + $this->postJson("/api/schools/{$schoolB}/attendance/scans", [ + 'student_number' => 'OTHER-2001', + 'scanned_at' => '2026-05-29T08:15:00Z', + 'source' => 'kiosk', + ]) + ); + + $this->assertTenantBlocked( + $this->postJson("/api/schools/{$schoolB}/communications/preview", [ + 'template_key' => 'attendance_absence', + 'student_id' => $studentB, + 'channel' => 'email', + ]) + ); + + $this->assertTenantBlocked( + $this->postJson("/api/schools/{$schoolB}/communications/send", [ + 'template_key' => 'attendance_absence', + 'student_id' => $studentB, + 'channel' => 'email', + ]) + ); + + $file = UploadedFile::fake()->createWithContent( + 'payments.csv', + "student_number,amount,paid_at,reference\nOTHER-2001,125.00,2026-05-29,PAY-BAD-001\n" + ); + + $this->assertTenantBlocked( + $this->postJson("/api/schools/{$schoolB}/finance/payment-files", [ + 'file' => $file, + ]) + ); + + $this->assertTenantBlocked( + $this->getJson("/api/schools/{$schoolB}/reports/daily?date=2026-05-29") + ); + + $this->assertDatabaseMissing('students', [ + 'school_id' => $schoolB, + 'student_identifier' => 'BAD-1001', + ]); + + $this->assertDatabaseMissing('payments', [ + 'school_id' => $schoolB, + 'reference_number' => 'PAY-BAD-001', + ]); + } + + public function test_guest_cannot_access_school_workflows(): void + { + $school = $this->createSchool('Guest Block School', 'GST'); + + $student = $this->createStudent($school, 'GUEST-1001', 'Guest'); + + $this->createTemplate( + $school, + 'attendance_absence', + 'email', + 'Hello, {{ student.first_name }} was marked absent.' + ); + + $this->getJson("/api/schools/{$school}") + ->assertUnauthorized(); + + $this->postJson("/api/schools/{$school}/students", [ + 'first_name' => 'Guest', + 'last_name' => 'Student', + 'student_number' => 'GUEST-2001', + 'grade_level' => '7', + ])->assertUnauthorized(); + + $this->getJson("/api/schools/{$school}/students/{$student}") + ->assertUnauthorized(); + + $this->postJson("/api/schools/{$school}/attendance/scans", [ + 'student_number' => 'GUEST-1001', + 'scanned_at' => '2026-05-29T08:15:00Z', + 'source' => 'kiosk', + ])->assertUnauthorized(); + + $this->postJson("/api/schools/{$school}/communications/preview", [ + 'template_key' => 'attendance_absence', + 'student_id' => $student, + 'channel' => 'email', + ])->assertUnauthorized(); + + $this->postJson("/api/schools/{$school}/communications/send", [ + 'template_key' => 'attendance_absence', + 'student_id' => $student, + 'channel' => 'email', + ])->assertUnauthorized(); + + $file = UploadedFile::fake()->createWithContent( + 'payments.csv', + "student_number,amount,paid_at,reference\nGUEST-1001,125.00,2026-05-29,PAY-GUEST-001\n" + ); + + $this->postJson("/api/schools/{$school}/finance/payment-files", [ + 'file' => $file, + ])->assertUnauthorized(); + + $this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29") + ->assertUnauthorized(); + } + + private function createSchool(string $name = 'Test School', ?string $code = null): int + { + return (int) DB::table('schools')->insertGetId([ + 'name' => $name, + 'code' => $code ?? 'SCH-'.uniqid(), + 'domain_profile' => 'standard_school', + 'timezone' => 'UTC', + 'locale' => 'en', + 'currency' => 'USD', + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int + { + return (int) DB::table('students')->insertGetId([ + 'school_id' => $school, + 'school_id_number' => $studentNumber, + 'student_identifier' => $studentNumber, + 'first_name' => $firstName, + 'last_name' => 'Patel', + 'status' => 'active', + 'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), + 'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createTemplate(int $school, string $key, string $channel, string $body): int + { + return (int) DB::table('communication_templates')->insertGetId([ + 'school_id' => $school, + 'template_key' => $key, + 'version' => '1', + 'status' => 'active', + 'channel' => $channel, + 'message_category' => 'general', + 'language' => 'en', + 'subject' => 'Notice for {{ student.first_name }}', + 'body' => $body, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createActorForSchool(int $school): Authenticatable + { + $this->ensureAuthTestTablesExist(); + + $userId = (int) DB::table('users')->insertGetId([ + 'name' => 'Test User '.$school, + 'email' => 'user'.$school.'@example.test', + 'password' => bcrypt('password'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('school_user')->insert([ + 'school_id' => $school, + 'user_id' => $userId, + 'role' => 'admin', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return new GenericUser([ + 'id' => $userId, + 'name' => 'Test User '.$school, + 'email' => 'user'.$school.'@example.test', + ]); + } + + private function ensureAuthTestTablesExist(): void + { + if (! Schema::hasTable('users')) { + Schema::create('users', function ($table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('school_user')) { + Schema::create('school_user', function ($table): void { + $table->id(); + $table->unsignedBigInteger('school_id')->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('role')->default('admin'); + $table->timestamps(); + + $table->unique(['school_id', 'user_id']); + }); + } + } + + private function assertTenantBlocked($response): void + { + $this->assertContains( + $response->getStatusCode(), + [403, 404], + 'Cross-tenant access must return either 403 or 404.' + ); + } +} \ No newline at end of file diff --git a/tests/Feature/Integration/SchoolWorkflowIntegrationTest.php b/tests/Feature/Integration/SchoolWorkflowIntegrationTest.php new file mode 100644 index 0000000..4e23e46 --- /dev/null +++ b/tests/Feature/Integration/SchoolWorkflowIntegrationTest.php @@ -0,0 +1,316 @@ +postJson('/api/schools', [ + 'name' => 'North Ridge School', + 'code' => 'NRS', + 'timezone' => 'America/New_York', + ]); + + $response->assertCreated() + ->assertJsonPath('data.name', 'North Ridge School') + ->assertJsonPath('data.code', 'NRS'); + +$schoolId = (int) $response->json('data.id'); + +$this->actingAsSchoolUser($schoolId); + +$this->getJson("/api/schools/{$schoolId}") + ->assertOk() + ->assertJsonPath('data.timezone', 'America/New_York'); + + $this->assertDatabaseHas('schools', [ + 'id' => $schoolId, + 'code' => 'NRS', + ]); + } + + public function test_student_can_be_created_for_school_and_isolated_by_school(): void + { + $schoolA = $this->createSchool('School A', 'S-A'); + $schoolB = $this->createSchool('School B', 'S-B'); + + $response = $this->postJson("/api/schools/{$schoolA}/students", [ + 'first_name' => 'Maya', + 'last_name' => 'Patel', + 'student_number' => 'STU-1001', + 'grade_level' => '7', + ]); + + $response->assertCreated() + ->assertJsonPath('data.first_name', 'Maya') + ->assertJsonPath('data.student_number', 'STU-1001'); + + $studentId = $response->json('data.id'); + + $this->getJson("/api/schools/{$schoolA}/students/{$studentId}") + ->assertOk() + ->assertJsonPath('data.grade_level', '7'); + + $this->getJson("/api/schools/{$schoolB}/students/{$studentId}") + ->assertNotFound(); + } + + public function test_attendance_scan_records_once_and_duplicate_scan_is_idempotent(): void + { + $school = $this->createSchool(); + $this->actingAsSchoolUser($school); + $student = $this->createStudent($school, 'STU-2001'); + + $payload = [ + 'student_number' => 'STU-2001', + 'scanned_at' => '2026-05-29T08:15:00Z', + 'source' => 'kiosk', + ]; + + $this->postJson("/api/schools/{$school}/attendance/scans", $payload) + ->assertCreated() + ->assertJsonPath('data.status', 'recorded'); + + $this->postJson("/api/schools/{$school}/attendance/scans", $payload) + ->assertOk() + ->assertJsonPath('data.status', 'duplicate'); + + $this->assertDatabaseHas('attendance_records', [ + 'school_id' => $school, + 'subject_type' => 'student', + 'subject_id' => $student, + 'status' => 'present', + ]); + + $this->assertSame(1, DB::table('attendance_records')->where('school_id', $school)->where('subject_id', $student)->count()); + $this->assertSame(2, DB::table('attendance_scan_logs')->where('school_id', $school)->where('subject_id', $student)->count()); + } + + public function test_communication_preview_has_no_side_effect_and_send_queues_recipient(): void + { + $school = $this->createSchool(); + $this->actingAsSchoolUser($school); + $student = $this->createStudent($school, 'STU-3001', 'Maya'); + $this->createTemplate($school, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.'); + + $this->postJson("/api/schools/{$school}/communications/preview", [ + 'template_key' => 'attendance_absence', + 'student_id' => $student, + 'channel' => 'email', + ])->assertOk() + ->assertJsonPath('data.body', 'Hello, Maya was marked absent.'); + + $this->assertDatabaseCount('communication_messages', 0); + $this->assertDatabaseCount('communication_message_recipients', 0); + + $this->postJson("/api/schools/{$school}/communications/send", [ + 'template_key' => 'attendance_absence', + 'student_id' => $student, + 'channel' => 'email', + ])->assertAccepted() + ->assertJsonPath('data.status', 'queued'); + + $this->assertDatabaseHas('communication_messages', [ + 'school_id' => $school, + 'channel' => 'email', + 'status' => 'queued', + ]); + $this->assertDatabaseHas('communication_message_recipients', [ + 'school_id' => $school, + 'recipient_type' => 'student', + 'recipient_id' => $student, + 'status' => 'queued', + ]); + } + + public function test_payment_file_imports_valid_rows_and_rejects_invalid_rows_without_partial_import(): void + { + $school = $this->createSchool(); + $this->actingAsSchoolUser($school); + $student = $this->createStudent($school, 'STU-4001'); + + $validFile = UploadedFile::fake()->createWithContent( + 'payments.csv', + "student_number,amount,paid_at,reference\nSTU-4001,125.00,2026-05-29,PAY-001\n" + ); + + $this->postJson("/api/schools/{$school}/finance/payment-files", [ + 'file' => $validFile, + ])->assertCreated() + ->assertJsonPath('data.imported_count', 1); + + $this->assertDatabaseHas('payments', [ + 'school_id' => $school, + 'student_id' => $student, + 'amount_minor' => 12500, + 'reference_number' => 'PAY-001', + ]); + + $invalidFile = UploadedFile::fake()->createWithContent( + 'bad-payments.csv', + "student_number,amount,paid_at,reference\nSTU-4001,80.00,2026-05-29,PAY-002\nMISSING,90.00,2026-05-29,PAY-003\n" + ); + + $this->postJson("/api/schools/{$school}/finance/payment-files", [ + 'file' => $invalidFile, + ])->assertUnprocessable(); + + $this->assertDatabaseMissing('payments', [ + 'school_id' => $school, + 'reference_number' => 'PAY-002', + ]); + $this->assertDatabaseMissing('payments', [ + 'school_id' => $school, + 'reference_number' => 'PAY-003', + ]); + } + + public function test_daily_report_aggregates_attendance_and_finance(): void + { + $school = $this->createSchool(); + $this->actingAsSchoolUser($school); + $student = $this->createStudent($school, 'STU-5001'); + + $this->postJson("/api/schools/{$school}/attendance/scans", [ + 'student_number' => 'STU-5001', + 'scanned_at' => '2026-05-29T08:15:00Z', + 'source' => 'kiosk', + ])->assertCreated(); + + DB::table('payments')->insert([ + 'school_id' => $school, + 'student_id' => $student, + 'amount_minor' => 12500, + 'currency' => 'USD', + 'payment_method' => 'manual', + 'payment_date' => '2026-05-29', + 'reference_number' => 'PAY-RPT-001', + 'status' => 'recorded', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29") + ->assertOk() + ->assertJsonPath('data.attendance.present_count', 1) + ->assertJsonPath('data.finance.total_payments', 12500); + } + + private function createSchool(string $name = 'Test School', ?string $code = null): int + { + return (int) DB::table('schools')->insertGetId([ + 'name' => $name, + 'code' => $code ?? 'SCH-'.uniqid(), + 'domain_profile' => 'standard_school', + 'timezone' => 'UTC', + 'locale' => 'en', + 'currency' => 'USD', + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int + { + return (int) DB::table('students')->insertGetId([ + 'school_id' => $school, + 'school_id_number' => $studentNumber, + 'student_identifier' => $studentNumber, + 'first_name' => $firstName, + 'last_name' => 'Patel', + 'status' => 'active', + 'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), + 'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createTemplate(int $school, string $key, string $channel, string $body): int + { + return (int) DB::table('communication_templates')->insertGetId([ + 'school_id' => $school, + 'template_key' => $key, + 'version' => '1', + 'status' => 'active', + 'channel' => $channel, + 'message_category' => 'general', + 'language' => 'en', + 'subject' => 'Notice for {{ student.first_name }}', + 'body' => $body, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function actingAsSchoolUser(int $school): Authenticatable + { + $this->ensureAuthTestTablesExist(); + + $userId = (int) DB::table('users')->insertGetId([ + 'name' => 'Workflow User '.$school, + 'email' => 'workflow'.$school.'@example.test', + 'password' => bcrypt('password'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('school_user')->insert([ + 'school_id' => $school, + 'user_id' => $userId, + 'role' => 'admin', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $user = new GenericUser([ + 'id' => $userId, + 'name' => 'Workflow User '.$school, + 'email' => 'workflow'.$school.'@example.test', + ]); + + $this->actingAs($user); + + return $user; + } + + private function ensureAuthTestTablesExist(): void + { + if (! Schema::hasTable('users')) { + Schema::create('users', function ($table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('school_user')) { + Schema::create('school_user', function ($table): void { + $table->id(); + $table->unsignedBigInteger('school_id')->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('role')->default('admin'); + $table->timestamps(); + + $table->unique(['school_id', 'user_id']); + }); + } + } +}