family = model(Family::class); $this->familyStudent = model(FamilyStudent::class); $this->familyGuardian = model(FamilyGuardian::class); $this->student = model(Student::class); $this->user = model(User::class); } public function index() { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $result = $this->paginate($this->family->newQuery()->orderBy('id'), $page, $perPage); foreach ($result['data'] as &$family) { $family['students'] = $this->familyStudent ->where('family_id', $family['id']) ->findAll(); $family['guardians'] = $this->familyGuardian ->where('family_id', $family['id']) ->findAll(); } return $this->success($result, 'Families retrieved successfully'); } public function show($id = null) { $family = $this->family->find($id); if (!$family) { return $this->respondError('Family not found', Response::HTTP_NOT_FOUND); } $family['students'] = $this->familyStudent->where('family_id', $id)->findAll(); $family['guardians'] = $this->familyGuardian->where('family_id', $id)->findAll(); return $this->success($family, 'Family retrieved successfully'); } public function getByStudent($id = null) { $familyIds = $this->familyStudent->where('student_id', $id)->findColumn('family_id'); if (empty($familyIds)) { return $this->success([], 'No families found for this student'); } $families = $this->family->whereIn('id', $familyIds)->get(); $payload = []; foreach ($families as $family) { $family->students = $this->familyStudent->where('family_id', $family->id)->findAll(); $family->guardians = $this->familyGuardian->where('family_id', $family->id)->findAll(); $payload[] = $family; } return $this->success($payload, 'Families retrieved successfully'); } public function getGuardians($id = null) { $family = $this->family->find($id); if (!$family) { return $this->respondError('Family not found', Response::HTTP_NOT_FOUND); } $guardians = $this->familyGuardian->where('family_id', $id)->findAll(); return $this->success($guardians, 'Guardians retrieved successfully'); } /** * Get families for a student * GET /api/v1/families/by-student/{student_id} */ public function familiesByStudent(int $studentId) { $rows = DB::table('family_students as fs') ->select('f.*', 'fs.is_primary_home') ->join('families as f', 'f.id', '=', 'fs.family_id') ->where('fs.student_id', $studentId) ->where('f.is_active', 1) ->orderByDesc('fs.is_primary_home') ->orderBy('f.household_name') ->get() ->map(fn($row) => (array) $row) ->toArray(); return $this->success(['data' => $rows], 'Families retrieved successfully'); } /** * Get guardians for a family * GET /api/v1/families/{family_id}/guardians */ public function guardiansByFamily(int $familyId) { $rows = DB::table('family_guardians as fg') ->select( 'u.id AS user_id', 'u.firstname', 'u.lastname', 'u.email', 'fg.relation', 'fg.is_primary', 'fg.receive_emails', 'fg.receive_sms' ) ->join('users as u', 'u.id', '=', 'fg.user_id') ->where('fg.family_id', $familyId) ->orderByDesc('fg.is_primary') ->orderBy('u.lastname') ->orderBy('u.firstname') ->get() ->map(fn($row) => (array) $row) ->toArray(); return $this->success(['data' => $rows], 'Guardians retrieved successfully'); } /** * Bootstrap families from existing students.parent_id * POST /api/v1/families/bootstrap */ public function bootstrap() { // Guard: ensure required tables exist foreach (['families', 'family_students', 'family_guardians'] as $t) { if (!DB::getSchemaBuilder()->hasTable($t)) { return $this->respondError( "Missing required table '{$t}'. Run migrations.", Response::HTTP_INTERNAL_SERVER_ERROR ); } } try { DB::beginTransaction(); // Get all distinct primary parents from students.parent_id $primaryParents = DB::table('students') ->select('parent_id') ->whereNotNull('parent_id') ->distinct() ->get() ->map(fn($row) => (array) $row) ->toArray(); $created = 0; $linkedStudents = 0; $linkedGuardians = 0; foreach ($primaryParents as $row) { $primaryUserId = (int) $row['parent_id']; if ($primaryUserId <= 0) continue; $familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created); // Link all students of this primary parent $students = DB::table('students') ->select('id') ->where('parent_id', $primaryUserId) ->get() ->map(fn($row) => (array) $row) ->toArray(); foreach ($students as $s) { $linkedStudents += $this->attachStudentToFamily((int) $s['id'], $familyId); } // Ensure primary parent is guardian on that family $linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0); } DB::commit(); return $this->success([ 'families_created' => $created, 'students_linked' => $linkedStudents, 'guardians_linked' => $linkedGuardians, ], 'Bootstrap completed successfully'); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Family bootstrap error: ' . $e->getMessage()); return $this->respondError('Failed to bootstrap families: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Attach second parent by user ID * POST /api/v1/families/attach-second-by-user */ public function attachSecondByUser() { $data = $this->payloadData(); $studentId = (int) ($data['student_id'] ?? 0); $userId = (int) ($data['user_id'] ?? 0); $relation = (string) ($data['relation'] ?? 'secondary'); if (!$studentId || !$userId) { return $this->respondError('student_id and user_id required', Response::HTTP_BAD_REQUEST); } $familyId = $this->familyIdFromStudentPrimary($studentId); if (!$familyId) { return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND); } $rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0); return $this->success([ 'attached' => $rows, 'family_id' => $familyId, ], 'Guardian attached successfully'); } /** * Attach second parent by email (creates user if needed) * POST /api/v1/families/attach-second-by-email */ public function attachSecondByEmail() { $data = $this->payloadData(); $studentId = (int) ($data['student_id'] ?? 0); $email = trim((string) ($data['email'] ?? '')); $first = trim((string) ($data['firstname'] ?? '')); $last = trim((string) ($data['lastname'] ?? '')); $relation = (string) ($data['relation'] ?? 'secondary'); if (!$studentId || !$email) { return $this->respondError('student_id and email required', Response::HTTP_BAD_REQUEST); } $familyId = $this->familyIdFromStudentPrimary($studentId); if (!$familyId) { return $this->respondError('No primary family found for this student', Response::HTTP_NOT_FOUND); } $user = $this->user->where('email', $email)->first(); if (!$user) { // Create a minimal/stub user $newUser = $this->user->create([ 'firstname' => $first ?: '', 'lastname' => $last ?: '', 'email' => $email, 'status' => 'Active', 'user_type' => 'secondary', ]); if (!$newUser || !isset($newUser['id'])) { return $this->respondError('Failed to create user', Response::HTTP_INTERNAL_SERVER_ERROR); } $userId = (int) $newUser['id']; } else { $userId = (int) $user['id']; } $rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0); return $this->success([ 'attached' => $rows, 'family_id' => $familyId, 'user_id' => $userId, ], 'Guardian attached successfully'); } /** * Set primary home flag for a student-family relationship * POST /api/v1/families/set-primary-home */ public function setPrimaryHome() { $data = $this->payloadData(); $familyId = (int) ($data['family_id'] ?? 0); $studentId = (int) ($data['student_id'] ?? 0); $flag = (int) ($data['is_primary_home'] ?? 0); if (!$familyId || !$studentId) { return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST); } try { $this->familyStudent ->where(['family_id' => $familyId, 'student_id' => $studentId]) ->update(['is_primary_home' => $flag ? 1 : 0]); return $this->success(null, 'Primary home flag updated successfully'); } catch (\Throwable $e) { log_message('error', 'Set primary home error: ' . $e->getMessage()); return $this->respondError('Failed to update primary home flag', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Set guardian flags (receive_emails, is_primary, receive_sms, relation) * POST /api/v1/families/set-guardian-flags */ public function setGuardianFlags() { $data = $this->payloadData(); $familyId = (int) ($data['family_id'] ?? 0); $userId = (int) ($data['user_id'] ?? 0); if (!$familyId || !$userId) { return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST); } $updateData = []; foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) { if (isset($data[$k])) { $val = $data[$k]; $updateData[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms']) ? (int) $val : (string) $val; } } if (empty($updateData)) { return $this->respondError('No flags provided', Response::HTTP_BAD_REQUEST); } try { $this->familyGuardian ->where(['family_id' => $familyId, 'user_id' => $userId]) ->update($updateData); return $this->success(null, 'Guardian flags updated successfully'); } catch (\Throwable $e) { log_message('error', 'Set guardian flags error: ' . $e->getMessage()); return $this->respondError('Failed to update guardian flags', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Unlink a guardian from a family * POST /api/v1/families/unlink-guardian */ public function unlinkGuardian() { $data = $this->payloadData(); $familyId = (int) ($data['family_id'] ?? 0); $userId = (int) ($data['user_id'] ?? 0); if (!$familyId || !$userId) { return $this->respondError('family_id and user_id required', Response::HTTP_BAD_REQUEST); } try { $this->familyGuardian ->where(['family_id' => $familyId, 'user_id' => $userId]) ->delete(); return $this->success(null, 'Guardian unlinked successfully'); } catch (\Throwable $e) { log_message('error', 'Unlink guardian error: ' . $e->getMessage()); return $this->respondError('Failed to unlink guardian', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Unlink a student from a family * POST /api/v1/families/unlink-student */ public function unlinkStudent() { $data = $this->payloadData(); $familyId = (int) ($data['family_id'] ?? 0); $studentId = (int) ($data['student_id'] ?? 0); if (!$familyId || !$studentId) { return $this->respondError('family_id and student_id required', Response::HTTP_BAD_REQUEST); } try { $this->familyStudent ->where(['family_id' => $familyId, 'student_id' => $studentId]) ->delete(); return $this->success(null, 'Student unlinked successfully'); } catch (\Throwable $e) { log_message('error', 'Unlink student error: ' . $e->getMessage()); return $this->respondError('Failed to unlink student', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Import second parents from legacy 'parents' table * POST /api/v1/families/import-second-parents */ public function importSecondParentsFromLegacy() { $L = [ 'table' => 'parents', 'firstparent_id' => 'firstparent_id', 'second_user_id' => 'secondparent_id', 'second_email' => 'secondparent_email', 'second_firstname' => 'secondparent_firstname', 'second_lastname' => 'secondparent_lastname', ]; if (!DB::getSchemaBuilder()->hasTable($L['table'])) { return $this->respondError("Legacy table '{$L['table']}' not found", Response::HTTP_NOT_FOUND); } foreach (['families', 'family_students', 'family_guardians'] as $t) { if (!DB::getSchemaBuilder()->hasTable($t)) { return $this->respondError( "Missing required table '{$t}'. Run migrations.", Response::HTTP_INTERNAL_SERVER_ERROR ); } } try { $rows = DB::table($L['table']) ->select( DB::raw("{$L['firstparent_id']} AS first_id"), DB::raw("{$L['second_user_id']} AS second_id"), DB::raw("{$L['second_email']} AS email"), DB::raw("{$L['second_firstname']} AS firstname"), DB::raw("{$L['second_lastname']} AS lastname") ) ->where(function ($query) use ($L) { $query->where($L['second_user_id'], '!=', 0) ->orWhere($L['second_email'], '!=', ''); }) ->get() ->map(fn($row) => (array) $row) ->toArray(); $createdUsers = 0; $linked = 0; $skipped = 0; foreach ($rows as $r) { $firstId = (int) ($r['first_id'] ?? 0); $secondId = (int) ($r['second_id'] ?? 0); $email = trim((string) ($r['email'] ?? '')); if (!$firstId || (!$secondId && $email === '')) { $skipped++; continue; } // Ensure/find family by primary parent $code = 'FAM-' . $firstId; $fam = $this->family->where('family_code', $code)->first(); if ($fam) { $familyId = (int) $fam['id']; } else { $tmp = 0; // counter sink $familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp); } // Resolve/create user $userId = 0; if ($secondId > 0) { $userId = $secondId; } else { $user = $this->user->where('email', $email)->first(); if (!$user) { $newUser = $this->user->create([ 'firstname' => $r['firstname'] ?? '', 'lastname' => $r['lastname'] ?? '', 'email' => $email, 'status' => 'Active', 'user_type' => 'secondary', ]); if ($newUser && isset($newUser['id'])) { $userId = (int) $newUser['id']; $createdUsers++; } else { $userId = 0; } } else { $userId = (int) $user['id']; } } if (!$userId) { $skipped++; continue; } // Ensure all students for this primary parent are linked to this family $stuRows = DB::table('students') ->select('id') ->where('parent_id', $firstId) ->get() ->map(fn($row) => (array) $row) ->toArray(); foreach ($stuRows as $sr) { $sid = (int) ($sr['id'] ?? 0); if ($sid > 0) { $this->attachStudentToFamily($sid, $familyId); } } // Link as guardian (idempotent) $linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0); } return $this->success([ 'created_users' => $createdUsers, 'guardians_linked' => $linked, 'skipped' => $skipped, 'total_source' => count($rows), ], 'Legacy import completed successfully'); } catch (\Throwable $e) { log_message('error', 'Import second parents error: ' . $e->getMessage()); return $this->respondError('Failed to import second parents: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /* ========================================================= * Private helpers * =======================================================*/ /** * Ensure there is exactly one family per primary parent (users.id) * Returns $familyId and increments $createdCounter by reference if newly created. */ protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int { $code = 'FAM-' . $primaryUserId; $row = $this->family->where('family_code', $code)->first(); if ($row) { return (int) $row['id']; } $family = $this->family->create([ 'family_code' => $code, 'household_name' => 'Family of User ' . $primaryUserId, 'is_active' => 1, ]); if ($family && isset($family['id'])) { $createdCounter++; return (int) $family['id']; } // If create failed, try to find it again (race condition) $row = $this->family->where('family_code', $code)->first(); if ($row) { return (int) $row['id']; } throw new \Exception('Failed to create family for primary parent ' . $primaryUserId); } /** * Attach a student to a family (idempotent; returns rows affected >0 if inserted) */ protected function attachStudentToFamily(int $studentId, int $familyId): int { // Check if already exists $exists = $this->familyStudent ->where('family_id', $familyId) ->where('student_id', $studentId) ->first(); if ($exists) { return 0; } // Insert $result = $this->familyStudent->create([ 'family_id' => $familyId, 'student_id' => $studentId, 'is_primary_home' => 1, ]); return ($result && isset($result['id'])) ? 1 : 0; } /** * Attach a guardian user to family (idempotent) */ protected function attachGuardianUser( int $familyId, int $userId, string $relation = 'primary', bool $isPrimary = false, int $receiveEmails = 1, int $receiveSms = 0 ): int { // Check if already exists $exists = $this->familyGuardian ->where('family_id', $familyId) ->where('user_id', $userId) ->first(); if ($exists) { return 0; } // Insert $result = $this->familyGuardian->create([ 'family_id' => $familyId, 'user_id' => $userId, 'relation' => $relation, 'is_primary' => $isPrimary ? 1 : 0, 'receive_emails' => $receiveEmails, 'receive_sms' => $receiveSms, ]); return ($result && isset($result['id'])) ? 1 : 0; } /** * Find the "primary" family for a student = family created from parent_id */ protected function familyIdFromStudentPrimary(int $studentId): ?int { // Get primary parent id for the student $st = $this->student->select('parent_id')->find($studentId); if (!$st || empty($st['parent_id'])) { return null; } // The canonical family uses code FAM-{parent_id} $code = 'FAM-' . (int) $st['parent_id']; $row = $this->family->select('id')->where('family_code', $code)->first(); if ($row) { return (int) $row['id']; } // If not found, fall back to any family linked already $row = DB::table('family_students') ->select('family_id') ->where('student_id', $studentId) ->orderByDesc('is_primary_home') ->first(); return $row ? (int) $row->family_id : null; } }