Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a40de4937 |
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Integration;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class SchoolWorkflowController extends Controller
|
||||
{
|
||||
public function storeSchool(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureSchoolAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
+70
-1
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
+12
@@ -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(
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\Integration\SchoolWorkflowController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/schools', [SchoolWorkflowController::class, 'storeSchool']);
|
||||
|
||||
Route::middleware(['auth', 'school.access'])->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,
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use Illuminate\Auth\GenericUser;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AuthTenantIsolationIntegrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_access_own_school_workflows(): void
|
||||
{
|
||||
$school = $this->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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Auth\GenericUser;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class SchoolWorkflowIntegrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_school_can_be_created_and_read_through_http(): void
|
||||
{
|
||||
$response = $this->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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user