diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 0bb8744..b4eefd4 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -2500,6 +2500,10 @@ class ParentController extends BaseController return redirect()->back()->with('error', 'Failed to delete student.'); } + // Keep the normalized household, guardian, contact, and remaining + // student membership data aligned after every child removal. + (new \App\Services\FamilyDataSyncService($this->db))->syncForParent((int) $parentId); + // ✅ Check if parent has any students left $remainingStudents = $this->studentModel ->where('parent_id', $parentId) diff --git a/app/Database/Migrations/2026-09-12-000100_BackfillMissingFamilyStudentMemberships.php b/app/Database/Migrations/2026-09-12-000100_BackfillMissingFamilyStudentMemberships.php new file mode 100644 index 0000000..1fa47c0 --- /dev/null +++ b/app/Database/Migrations/2026-09-12-000100_BackfillMissingFamilyStudentMemberships.php @@ -0,0 +1,78 @@ +db->tableExists($table)) { + return; + } + } + + $students = $this->db->query( + "SELECT s.id AS student_id, s.parent_id + FROM students s + LEFT JOIN family_students fs ON fs.student_id = s.id + WHERE fs.id IS NULL + AND s.parent_id IS NOT NULL + AND s.parent_id > 0 + ORDER BY s.id" + )->getResultArray(); + + foreach ($students as $student) { + $studentId = (int) ($student['student_id'] ?? 0); + $parentId = (int) ($student['parent_id'] ?? 0); + if ($studentId <= 0 || $parentId <= 0) { + continue; + } + + $family = $this->db->query( + "SELECT f.id + FROM families f + LEFT JOIN family_guardians fg + ON fg.family_id = f.id + AND fg.user_id = ? + WHERE f.family_code = ? OR fg.user_id = ? + ORDER BY (f.family_code = ?) DESC, fg.is_primary DESC, f.id ASC + LIMIT 1", + [$parentId, 'FAM-' . $parentId, $parentId, 'FAM-' . $parentId] + )->getRowArray(); + + if (empty($family['id'])) { + $parent = $this->db->table('users') + ->select('firstname, lastname') + ->where('id', $parentId) + ->get() + ->getRowArray(); + $parentName = trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')); + + $this->db->table('families')->insert([ + 'family_code' => 'FAM-' . $parentId, + 'household_name' => $parentName !== '' ? 'Family of ' . $parentName : 'Family of User ' . $parentId, + 'is_active' => 1, + ]); + $family['id'] = (int) $this->db->insertID(); + } + + $this->db->query( + 'INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms) VALUES (?, ?, ?, 1, 1, 0)', + [(int) $family['id'], $parentId, 'primary'] + ); + $this->db->query( + 'INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home) VALUES (?, ?, 1)', + [(int) $family['id'], $studentId] + ); + } + } + + public function down(): void + { + // Data repair is intentionally not reversed: a repaired membership is + // indistinguishable from one subsequently confirmed by an administrator. + } +} diff --git a/app/Database/Migrations/2026-09-12-000200_CreateMissingStudentFamilies.php b/app/Database/Migrations/2026-09-12-000200_CreateMissingStudentFamilies.php new file mode 100644 index 0000000..13b30ff --- /dev/null +++ b/app/Database/Migrations/2026-09-12-000200_CreateMissingStudentFamilies.php @@ -0,0 +1,82 @@ +db->tableExists($table)) { + return; + } + } + + $students = $this->db->query( + "SELECT s.id AS student_id, s.parent_id, u.firstname, u.lastname + FROM students s + JOIN users u ON u.id = s.parent_id + LEFT JOIN family_students fs ON fs.student_id = s.id + WHERE fs.id IS NULL + AND s.parent_id IS NOT NULL + AND s.parent_id > 0 + ORDER BY s.id" + )->getResultArray(); + + foreach ($students as $student) { + $studentId = (int) ($student['student_id'] ?? 0); + $parentId = (int) ($student['parent_id'] ?? 0); + if ($studentId <= 0 || $parentId <= 0) { + continue; + } + + $family = $this->db->query( + "SELECT f.id + FROM families f + LEFT JOIN family_guardians fg + ON fg.family_id = f.id + AND fg.user_id = ? + WHERE f.family_code = ? OR fg.user_id = ? + ORDER BY (f.family_code = ?) DESC, fg.is_primary DESC, f.id ASC + LIMIT 1", + [$parentId, 'FAM-' . $parentId, $parentId, 'FAM-' . $parentId] + )->getRowArray(); + + if (empty($family['id'])) { + $parentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $this->db->table('families')->insert([ + 'family_code' => 'FAM-' . $parentId, + 'household_name' => $parentName !== '' ? 'Family of ' . $parentName : 'Family of User ' . $parentId, + 'is_active' => 1, + ]); + $family['id'] = (int) $this->db->insertID(); + } + + $familyId = (int) ($family['id'] ?? 0); + if ($familyId <= 0) { + throw new \RuntimeException('Could not create a family for parent ID ' . $parentId . '.'); + } + + $this->db->query( + 'INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms) VALUES (?, ?, ?, 1, 1, 0)', + [$familyId, $parentId, 'primary'] + ); + $this->db->query( + 'INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home) VALUES (?, ?, 1)', + [$familyId, $studentId] + ); + } + } + + public function down(): void + { + // This repair is intentionally irreversible for the same reason as the + // preceding family-membership backfill. + } +} diff --git a/app/Services/FamilyDataSyncService.php b/app/Services/FamilyDataSyncService.php new file mode 100644 index 0000000..696ef02 --- /dev/null +++ b/app/Services/FamilyDataSyncService.php @@ -0,0 +1,120 @@ +db->tableExists($table)) { + return null; + } + } + + $parent = $this->db->table('users') + ->select('id, firstname, lastname, cellphone, address_street, apt, city, state, zip') + ->where('id', $parentId) + ->get() + ->getRowArray(); + if (! $parent) { + return null; + } + + $familyCode = 'FAM-' . $parentId; + $family = $this->db->table('families') + ->select('id') + ->where('family_code', $familyCode) + ->get() + ->getRowArray(); + + if (! $family) { + $family = $this->db->table('family_guardians fg') + ->select('fg.family_id AS id') + ->join('families f', 'f.id = fg.family_id', 'inner') + ->where('fg.user_id', $parentId) + ->orderBy('fg.is_primary', 'DESC') + ->orderBy('fg.id', 'ASC') + ->get(1) + ->getRowArray(); + } + + $familyData = $this->familyData($parent); + if (! $family) { + $this->db->table('families')->insert(['family_code' => $familyCode] + $familyData); + $familyId = (int) $this->db->insertID(); + } else { + $familyId = (int) ($family['id'] ?? 0); + if ($familyId > 0) { + $this->db->table('families')->where('id', $familyId)->update($familyData); + } + } + + if ($familyId <= 0) { + throw new \RuntimeException('Family data could not be synchronized for parent ID ' . $parentId . '.'); + } + + $this->db->query( + 'INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms) VALUES (?, ?, ?, 1, 1, 0)', + [$familyId, $parentId, 'primary'] + ); + + $students = $this->db->table('students') + ->select('id') + ->where('parent_id', $parentId) + ->get() + ->getResultArray(); + foreach ($students as $student) { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId > 0) { + $this->db->query( + 'INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home) VALUES (?, ?, 1)', + [$familyId, $studentId] + ); + } + } + + // Normally handled by the foreign key cascade; this also heals older + // databases where that constraint was absent. + $this->db->query( + 'DELETE fs FROM family_students fs LEFT JOIN students s ON s.id = fs.student_id WHERE fs.family_id = ? AND s.id IS NULL', + [$familyId] + ); + + return $familyId; + } + + private function familyData(array $parent): array + { + $parentName = trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')); + $data = [ + 'household_name' => $parentName !== '' ? 'Family of ' . $parentName : 'Family of User ' . (int) $parent['id'], + 'address_line1' => trim((string) ($parent['address_street'] ?? '')), + 'address_line2' => trim((string) ($parent['apt'] ?? '')), + 'city' => trim((string) ($parent['city'] ?? '')), + 'state' => trim((string) ($parent['state'] ?? '')), + 'postal_code' => trim((string) ($parent['zip'] ?? '')), + 'primary_phone' => trim((string) ($parent['cellphone'] ?? '')), + 'is_active' => 1, + ]; + if ($this->db->fieldExists('updated_at', 'families')) { + $data['updated_at'] = utc_now(); + } + + return $data; + } +} diff --git a/app/Services/Parents/ParentRegistrationService.php b/app/Services/Parents/ParentRegistrationService.php index 8336454..9a785ff 100644 --- a/app/Services/Parents/ParentRegistrationService.php +++ b/app/Services/Parents/ParentRegistrationService.php @@ -10,6 +10,7 @@ use App\Models\StudentModel; use App\Models\UserModel; use App\Services\PhoneFormatterService; use App\Services\SchoolIdService; +use App\Services\FamilyDataSyncService; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use DateTime; @@ -296,6 +297,12 @@ class ParentRegistrationService } } + // Deleting a student cascades the normalized family_students row, but + // intentionally leaves the household and guardian records in place. + // Always restore that membership when a child is added again (and heal + // older registrations that pre-date the normalized family tables). + (new FamilyDataSyncService($this->db))->syncForParent($parentId); + $this->medicalConditionModel->where('student_id', $studentId)->delete(); foreach ((array) $conditions as $condition) { $condition = trim((string) $condition); diff --git a/app/Views/family/card.php b/app/Views/family/card.php index f5fde55..0367704 100644 --- a/app/Views/family/card.php +++ b/app/Views/family/card.php @@ -181,7 +181,6 @@ if ($returnUrl === '') { 0 && $selectedStudentId === $studentId; $allergies = array_values(array_filter(array_map('trim', (array)($s['allergies'] ?? [])))); $conditions = array_values(array_filter(array_map('trim', (array)($s['medical_conditions'] ?? [])))); ?> @@ -189,10 +188,10 @@ if ($returnUrl === '') {