fix family card issue
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m27s

This commit is contained in:
root
2026-09-12 03:16:10 -04:00
parent ce504c933e
commit 1787144f27
11 changed files with 382 additions and 11 deletions
@@ -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)
@@ -0,0 +1,78 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class BackfillMissingFamilyStudentMemberships extends Migration
{
public function up(): void
{
foreach (['users', 'students', 'families', 'family_guardians', 'family_students'] as $table) {
if (! $this->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.
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Completes the family-link repair for databases that already ran the first
* backfill before students without any household record were discovered.
*/
final class CreateMissingStudentFamilies extends Migration
{
public function up(): void
{
foreach (['users', 'students', 'families', 'family_guardians', 'family_students'] as $table) {
if (! $this->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.
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
/**
* Keeps the normalized family record aligned with its primary parent and
* every current student owned by that parent.
*/
final class FamilyDataSyncService
{
public function __construct(private readonly BaseConnection $db)
{
}
public function syncForParent(int $parentId): ?int
{
if ($parentId <= 0) {
return null;
}
foreach (['users', 'students', 'families', 'family_guardians', 'family_students'] as $table) {
if (! $this->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;
}
}
@@ -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);
+3 -4
View File
@@ -181,7 +181,6 @@ if ($returnUrl === '') {
<?php
$studentId = (int)($s['id'] ?? 0);
$detailId = 'fc-student-details-' . (int)($f['id'] ?? 0) . '-' . $studentId;
$isSelected = $selectedStudentId > 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 === '') {
<h2 class="accordion-header">
<button
type="button"
class="accordion-button <?= $isSelected ? '' : 'collapsed' ?>"
class="accordion-button collapsed"
data-bs-toggle="collapse"
data-bs-target="#<?= esc($detailId) ?>"
aria-expanded="<?= $isSelected ? 'true' : 'false' ?>"
aria-expanded="false"
aria-controls="<?= esc($detailId) ?>"
>
<span class="fc-name">
@@ -206,7 +205,7 @@ if ($returnUrl === '') {
<div
id="<?= esc($detailId) ?>"
class="accordion-collapse collapse <?= $isSelected ? 'show' : '' ?>"
class="accordion-collapse collapse"
data-bs-parent="#fc-students-<?= (int)($f['id'] ?? 0) ?>"
>
<div class="accordion-body fc-student-details">
+14 -2
View File
@@ -356,8 +356,20 @@ html, body { overflow-x: hidden; }
try {
const gid = params && params.guardian_id;
const sid = params && params.student_id;
if (gid) window.location.href = '<?= site_url('family') ?>' + '?guardian_id=' + encodeURIComponent(gid);
else if (sid) window.location.href = '<?= site_url('family') ?>' + '?student_id=' + encodeURIComponent(sid);
const familyUrl = new URL('<?= site_url('family') ?>', window.location.origin);
const currentPath = window.location.pathname.replace(/\/+$/, '');
const familyPath = familyUrl.pathname.replace(/\/+$/, '');
const familyIndexPath = familyPath + '/index';
if (currentPath !== familyPath && currentPath !== familyIndexPath && (gid || sid)) {
familyUrl.searchParams.set(gid ? 'guardian_id' : 'student_id', gid || sid);
window.location.href = familyUrl.toString();
return;
}
const target = document.getElementById('familyCardContent');
if (target) target.innerHTML = '<div class="p-3 text-danger">The family card could not be loaded. Please refresh and try again.</div>';
const m = ensureModal();
if (m) m.show();
} catch(_) {}
}
}
+17 -3
View File
@@ -614,12 +614,26 @@
const m = ensureModal();
if (m) m.show();
} catch (e) {
// Fallback: redirect to /family filtered page
try {
const gid = params && params.guardian_id;
const sid = params && params.student_id;
if (gid) window.location.href = '<?= site_url('family') ?>' + '?guardian_id=' + encodeURIComponent(gid);
else if (sid) window.location.href = '<?= site_url('family') ?>' + '?student_id=' + encodeURIComponent(sid);
const familyUrl = new URL('<?= site_url('family') ?>', window.location.origin);
const currentPath = window.location.pathname.replace(/\/+$/, '');
const familyPath = familyUrl.pathname.replace(/\/+$/, '');
const familyIndexPath = familyPath + '/index';
// A failed card request used to redirect the family page back to
// itself. Its automatic card loader then repeated that forever.
if (currentPath !== familyPath && currentPath !== familyIndexPath && (gid || sid)) {
familyUrl.searchParams.set(gid ? 'guardian_id' : 'student_id', gid || sid);
window.location.href = familyUrl.toString();
return;
}
const target = document.getElementById('familyCardContent');
if (target) target.innerHTML = '<div class="p-3 text-danger">The family card could not be loaded. Please refresh and try again.</div>';
const m = ensureModal();
if (m) m.show();
} catch(_) {}
}
}
@@ -0,0 +1,29 @@
<?php
namespace Tests\App\Services;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyDataSyncWiringTest extends CIUnitTestCase
{
public function testStudentAddAndRemoveFlowsBothSynchronizeFamilyData(): void
{
$registration = file_get_contents(APPPATH . 'Services/Parents/ParentRegistrationService.php') ?: '';
$parentController = file_get_contents(APPPATH . 'Controllers/View/ParentController.php') ?: '';
$this->assertStringContainsString('FamilyDataSyncService($this->db))->syncForParent($parentId)', $registration);
$this->assertStringContainsString('FamilyDataSyncService($this->db))->syncForParent((int) $parentId)', $parentController);
}
public function testFamilySyncRefreshesContactDataAndAllCurrentStudents(): void
{
$source = file_get_contents(APPPATH . 'Services/FamilyDataSyncService.php') ?: '';
foreach (['household_name', 'address_line1', 'address_line2', 'city', 'state', 'postal_code', 'primary_phone'] as $field) {
$this->assertStringContainsString("'{$field}'", $source);
}
$this->assertStringContainsString("->where('parent_id', \$parentId)", $source);
$this->assertStringContainsString('INSERT IGNORE INTO family_students', $source);
$this->assertStringContainsString('INSERT IGNORE INTO family_guardians', $source);
}
}
@@ -0,0 +1,23 @@
<?php
namespace Tests\App\Views;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyCardLoaderLoopTest extends CIUnitTestCase
{
public function testFamilyLayoutsDoNotRedirectFamilyPageToItselfAfterCardFailure(): void
{
foreach (['management_layout.php', 'main_layout.php'] as $layout) {
$source = file_get_contents(APPPATH . 'Views/layout/' . $layout) ?: '';
$this->assertStringContainsString("const familyIndexPath = familyPath + '/index';", $source, $layout);
$this->assertStringContainsString(
'currentPath !== familyPath && currentPath !== familyIndexPath',
$source,
$layout
);
$this->assertStringContainsString('The family card could not be loaded.', $source, $layout);
}
}
}
+5 -2
View File
@@ -6,7 +6,7 @@ use CodeIgniter\Test\CIUnitTestCase;
final class FamilyCardViewTest extends CIUnitTestCase
{
public function testStudentNameExpandsCompleteStudentDetails(): void
public function testStudentNameKeepsCompleteStudentDetailsCollapsedInitially(): void
{
helper('time');
@@ -59,7 +59,10 @@ final class FamilyCardViewTest extends CIUnitTestCase
$this->assertStringContainsString('Test Student', $html);
$this->assertStringContainsString('data-bs-target="#fc-student-details-9-12"', $html);
$this->assertStringContainsString('accordion-collapse collapse show', $html);
$this->assertStringContainsString('class="accordion-button collapsed"', $html);
$this->assertStringContainsString('aria-expanded="false"', $html);
$this->assertStringContainsString('class="accordion-collapse collapse"', $html);
$this->assertStringNotContainsString('accordion-collapse collapse show', $html);
$this->assertStringContainsString('Student Details', $html);
$this->assertStringContainsString('STU-12', $html);
$this->assertStringContainsString('5 / 5-A', $html);