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); } }