classSectionIds -> links -> send * - Class mode: given classSectionId, find students -> parents (primary+second) -> send link * - All mode: iterate all classSectionIds in student_class, gather recipients -> send per parent */ class WhatsappController extends BaseController { protected $db; protected $configModel; protected $semester; protected $schoolYear; /** cache for presence of roster rows per term */ private array $rosterPresenceCache = []; protected ParentModel $parentsModel; protected StudentModel $studentModel; protected ClassSectionModel $classSectionModel; protected UserModel $userModel; protected EnrollmentModel $enrollmentModel; protected WhatsappGroupLinkModel $linkModel; protected WhatsappInviteLogModel $logModel; protected WhatsappGroupMembershipModel $membershipModel; public function __construct() { helper('url'); // Load the database service $this->db = \Config\Database::connect(); // Init models $this->userModel = new UserModel(); $this->studentModel = new StudentModel(); $this->classSectionModel = new ClassSectionModel(); $this->linkModel = new WhatsappGroupLinkModel(); $this->membershipModel = new WhatsappGroupMembershipModel(); $this->configModel = new ConfigurationModel(); $this->semester = $this->configModel->getConfig('semester'); $this->schoolYear = $this->configModel->getConfig('school_year'); } /** * Admin page: pre-load ALL parents (primaries + second-parents) and ALL sections (from student_class). */ public function index() { // This page is year-only: ignore semester filters. $semester = ''; [$parents, $sections] = $this->prepareIndexData($this->schoolYear, $semester); // Build links map for small UI hints/badges $links = $this->linkModel->getAllForTerm($this->schoolYear, $semester); $linksBySection = []; foreach ($links as $l) { $cid = (int) ($l['class_section_id'] ?? 0); if ($cid && !isset($linksBySection[$cid])) { $linksBySection[$cid] = $l; } } return view('whatsapp/manage_links', [ 'sections' => $sections, // [ ['class_section_id','class_section_name'], ... ] 'parents' => $parents, // [ ['id','firstname','lastname','email','source'], ... ] 'linksBySection' => $linksBySection, // [class_section_id => linkRow] 'schoolYear' => $this->schoolYear, 'semester' => $semester, ]); } /** * Create/update a WhatsApp link for a class section. */ public function saveLink() { $sectionId = (int) $this->request->getPost('class_section_id'); $sectionName = (string) ($this->request->getPost('class_section_name') ?? ''); $inviteLink = (string) $this->request->getPost('invite_link'); $active = (int) ($this->request->getPost('active') ? 1 : 0); if (!$sectionId || !$inviteLink) { return redirect()->back()->with('error', 'Class and link are required.'); } // Resolve display name if not provided if ($sectionName === '') { $row = $this->linkModel->getLinkForSection($sectionId, $this->schoolYear, $this->semester); $sectionName = $row['class_section_name'] ?? ('Section ' . $sectionId); } $existing = $this->linkModel->where([ 'class_section_id' => $sectionId, 'school_year' => $this->schoolYear, 'semester' => $this->semester, ])->first(); $payload = [ 'class_section_id' => $sectionId, 'class_section_name' => $sectionName, 'school_year' => $this->schoolYear, 'semester' => $this->semester, 'invite_link' => trim($inviteLink), 'active' => $active, ]; if ($existing) { $this->linkModel->update($existing['id'], $payload); } else { $this->linkModel->insert($payload); } return redirect()->back()->with('success', 'WhatsApp group link saved.'); } /** * Send invites in three modes: 'parents' | 'class' | 'all' * POST fields: * - mode: 'parents' | 'class' | 'all' * - class_section_id: int (required for mode='class') * - parent_ids[]: array (required for mode='parents'; can be primary users.id or parents.id) */ // Controller method public function sendInvites() { // This page is year-only: ignore semester filters. $semester = ''; $mode = (string) trim((string) $this->request->getPost('mode')); $parentIdsPicked = array_map('intval', (array) ($this->request->getPost('parent_ids') ?? [])); $rawClassId = $this->request->getPost('class_section_id'); $classSectionId = ($rawClassId === '' || $rawClassId === null) ? 0 : (int) $rawClassId; if (!in_array($mode, ['parents', 'class', 'all'], true)) { return redirect()->back()->with('error', 'Invalid mode.'); } if ($mode === 'parents' && empty(array_filter($parentIdsPicked))) { return redirect()->back()->with('error', 'Please select at least one parent.'); } if ($mode === 'class' && $classSectionId <= 0) { return redirect()->back()->with('error', 'Please select a class/section.'); } // Load all links for the term once $links = $this->linkModel->getAllForTerm($this->schoolYear, $semester); $linkBySection = []; foreach ($links as $l) { $cid = (int) ($l['class_section_id'] ?? 0); if ($cid && !isset($linkBySection[$cid])) { $linkBySection[$cid] = $l; } } // Build per-family bundles $payloads = []; if ($mode === 'parents') { foreach ($parentIdsPicked as $pickedId) { if ($pickedId <= 0) continue; $bundle = $this->bundleForSingleParent($pickedId, $linkBySection); if ($bundle && !empty($bundle['emails']) && !empty($bundle['links'])) { $payloads[] = $bundle; } } } elseif ($mode === 'class') { $bundles = $this->bundleForSingleClass($classSectionId, $linkBySection); // per-parent bundles foreach ($bundles as $b) { if (!empty($b['emails']) && !empty($b['links'])) { $payloads[] = $b; } } } else { // 'all' $bundles = $this->bundlesForAllParentsAllClasses($linkBySection); // per-parent bundles across all foreach ($bundles as $b) { if (!empty($b['emails']) && !empty($b['links'])) { $payloads[] = $b; } } } if (empty($payloads)) { return redirect()->back()->with('error', 'No recipients found (missing links or emails).'); } /** * Consolidate per-student bundles into one bundle per parent. * Key priority: parent id; fallback: normalized primary emails joined. */ $normEmailsList = static function (array $list): array { $out = []; foreach ($list as $e) { $e = strtolower(trim((string) $e)); if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { $out[$e] = true; } } return array_keys($out); }; $mergeUniqueList = static function (array $a, array $b): array { // preserves scalar lists, case-sensitive for display fields $map = []; foreach (array_merge($a, $b) as $v) { $k = is_array($v) ? md5(json_encode($v)) : (string) $v; $map[$k] = $v; } return array_values($map); }; /** * Merge "sections" as associative rows; prefer dedupe by class_section_id if present, * otherwise by name string. */ $mergeSections = static function (array $A, array $B): array { $byKey = []; $put = static function (&$byKey, $row) { if (is_array($row)) { $id = $row['class_section_id'] ?? null; $name = $row['class_section_name'] ?? null; $key = $id !== null ? ('id:' . (int)$id) : ('name:' . (string)$name); $byKey[$key] = $row; } else { // string fallback $byKey['name:' . (string)$row] = $row; } }; foreach ($A as $r) $put($byKey, $r); foreach ($B as $r) $put($byKey, $r); return array_values($byKey); }; /** * Merge "links" items. Accepts mixed shapes: * - array of link items (recommended): ['class_section_id','class_section_name','invite_link','qr_src'] * - string URL(s) (legacy) */ $mergeLinks = static function (array $A, array $B): array { $index = []; $add = static function (&$index, $item) { if (is_array($item)) { $key = (isset($item['class_section_id']) && $item['class_section_id'] !== null) ? 'id:' . (int)$item['class_section_id'] : ('url:' . (string)($item['invite_link'] ?? json_encode($item))); $index[$key] = $item; } else { $index['url:' . (string)$item] = $item; } }; foreach ($A as $it) $add($index, $it); foreach ($B as $it) $add($index, $it); return array_values($index); }; $grouped = []; foreach ($payloads as $b) { $parent = $b['parent'] ?? []; $pid = (int)($parent['id'] ?? 0); $primary = $normEmailsList((array)($b['emails'] ?? [])); $groupKey = $pid > 0 ? ('pid:' . $pid) : ('mail:' . implode(',', $primary)); if (!isset($grouped[$groupKey])) { $grouped[$groupKey] = [ 'parent' => $parent, 'emails' => $primary, 'sections' => (array)($b['sections'] ?? []), 'links' => (array)($b['links'] ?? []), 'teachers' => (array)($b['teachers'] ?? []), // Keep any "items" shape if your listener/view uses it 'items' => (array)($b['items'] ?? []), ]; } else { // Merge $grouped[$groupKey]['parent'] = !empty($parent) ? $parent : $grouped[$groupKey]['parent']; $grouped[$groupKey]['emails'] = $normEmailsList(array_merge($grouped[$groupKey]['emails'], $primary)); $grouped[$groupKey]['sections'] = $mergeSections($grouped[$groupKey]['sections'], (array)($b['sections'] ?? [])); $grouped[$groupKey]['links'] = $mergeLinks($grouped[$groupKey]['links'], (array)($b['links'] ?? [])); $grouped[$groupKey]['teachers'] = $mergeUniqueList($grouped[$groupKey]['teachers'], (array)($b['teachers'] ?? [])); $grouped[$groupKey]['items'] = $mergeLinks($grouped[$groupKey]['items'], (array)($b['items'] ?? [])); // treat items like links } } // Final consolidated list $payloads = array_values($grouped); // Ensure a listener is actually registered; otherwise nothing will fire. $listenerCount = method_exists(\CodeIgniter\Events\Events::class, 'listeners') ? count(\CodeIgniter\Events\Events::listeners('whatsapp_invites.send')) : -1; log_message('debug', 'whatsapp_invites.send listener count: {c}', ['c' => $listenerCount]); if ($listenerCount === 0) { return redirect()->back()->with( 'error', 'No event listener registered for whatsapp_invites.send. Add one in app/Config/Events.php.' ); } // Helpers $norm = static function (array $list): array { $out = []; foreach ($list as $e) { $e = strtolower(trim((string) $e)); if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { $out[$e] = true; } } return array_keys($out); }; // Extract secondary emails from bundle shapes + DB fallback $getSecondaryEmails = function (array $bundle) use ($norm) { $collected = []; // Explicit arrays from bundlers if (!empty($bundle['emails_secondary'])) { $collected = array_merge($collected, (array) $bundle['emails_secondary']); } if (!empty($bundle['secondary_parents'])) { foreach ((array) $bundle['secondary_parents'] as $sp) { if (!empty($sp['email'])) $collected[] = $sp['email']; } } // Common single-field shapes on parent $p = $bundle['parent'] ?? []; foreach (['secondary_email', 'secondparent_email', 'sec_email', 'email_secondary'] as $k) { if (!empty($p[$k])) $collected[] = $p[$k]; } if (!empty($p['secondparent']['email'])) $collected[] = $p['secondparent']['email']; // DB fallback by firstparent_id == primary parent id try { $primaryId = (int) ($p['id'] ?? 0); if ($primaryId > 0) { $row = $this->db->table('parents') ->select('secondparent_email') ->where('firstparent_id', $primaryId) ->orderBy('updated_at', 'DESC') ->get()->getFirstRow('array'); if (!empty($row['secondparent_email'])) { $collected[] = $row['secondparent_email']; } } } catch (\Throwable $e) { log_message('error', 'Secondary email DB lookup failed: {err}', ['err' => $e->getMessage()]); } return $norm($collected); }; // Trigger one event per family: TO = primary; CC = secondary $triggered = 0; foreach ($payloads as $b) { try { $primaryEmails = $norm((array) ($b['emails'] ?? [])); $secondaryEmails = $getSecondaryEmails($b); if (empty($secondaryEmails)) { log_message('debug', 'No secondary_emails for parent_id={pid}; bundle keys={keys}', [ 'pid' => $b['parent']['id'] ?? null, 'keys' => implode(',', array_keys($b)), ]); } $to = $primaryEmails; $cc = []; if (!empty($primaryEmails) && !empty($secondaryEmails)) { // keep CC distinct from TO $cc = array_values(array_diff($secondaryEmails, $primaryEmails)); } elseif (empty($primaryEmails) && !empty($secondaryEmails)) { $to = $secondaryEmails; // only secondary exists → send to them } if (empty($to)) { log_message('warning', 'Skipping whatsapp_invites.send: no recipient email(s) for parent_id {pid}', [ 'pid' => $b['parent']['id'] ?? null ]); continue; } \CodeIgniter\Events\Events::trigger('whatsapp_invites.send', [ 'parent' => $b['parent'] ?? null, 'sections' => $b['sections'] ?? [], 'schoolYear' => $this->schoolYear, 'semester' => $this->semester, 'links' => $b['links'] ?? [], 'teachers' => $b['teachers'] ?? [], 'to_emails' => $to, 'cc_emails' => $cc, ]); $triggered++; } catch (\Throwable $e) { log_message('error', 'whatsapp_invites.send failed: {err}', ['err' => $e->getMessage()]); } } return redirect()->back()->with('success', "Invite processing started for {$triggered} recipient group(s)."); } // ───────────────────────────────────────────────────────────────────────────── // Internal helpers (new logic) // ───────────────────────────────────────────────────────────────────────────── public function parentContacts() { // Term context (use controller properties if you already set them) $schoolYear = $this->schoolYear ?? (string)($this->request->getGet('school_year') ?? ''); $semester = $this->semester ?? (string)($this->request->getGet('semester') ?? ''); // parents = term-scoped table holding second parent fields + firstparent_id // users = primary parent records $b = $this->db->table('parents sp'); $b->select(" sp.id AS parents_row_id, sp.firstparent_id AS firstparent_id, sp.secondparent_firstname AS sp_firstname, sp.secondparent_lastname AS sp_lastname, sp.secondparent_email AS sp_email, sp.secondparent_phone AS sp_phone, sp.school_year AS sp_school_year, sp.semester AS sp_semester, u.id AS u_id, u.firstname AS u_firstname, u.lastname AS u_lastname, u.email AS u_email, u.cellphone AS u_phone, u.school_year AS u_school_year, u.semester AS u_semester "); $b->join('users u', 'u.id = sp.firstparent_id', 'left'); // Scope by term using parents table (authoritative for the pairing) if ($schoolYear !== '') { $b->where('sp.school_year', $schoolYear); } if ($semester !== '') { $b->where('sp.semester', $semester); } $rows = $b->get()->getResultArray(); // Flatten into a single contacts list (primary + second parent as separate rows) $contacts = []; foreach ($rows as $r) { // 1) Primary (first) parent from users if (!empty($r['u_id'])) { $contacts[] = [ 'firstname' => (string)($r['u_firstname'] ?? ''), 'lastname' => (string)($r['u_lastname'] ?? ''), 'email' => (string)($r['u_email'] ?? ''), 'phone' => (string)($r['u_phone'] ?? ''), 'type' => 'Primary Parent', ]; } // 2) Second parent from parents (only if we have at least a name/phone/email) $hasSecond = !empty($r['sp_firstname']) || !empty($r['sp_lastname']) || !empty($r['sp_email']) || !empty($r['sp_phone']); if ($hasSecond) { $contacts[] = [ 'firstname' => (string)($r['sp_firstname'] ?? ''), 'lastname' => (string)($r['sp_lastname'] ?? ''), 'email' => (string)($r['sp_email'] ?? ''), 'phone' => (string)($r['sp_phone'] ?? ''), 'type' => 'Second Parent', ]; } } // Optional: remove totally empty lines (no name + no contact) $contacts = array_values(array_filter($contacts, function ($c) { $name = trim(($c['firstname'] ?? '') . ($c['lastname'] ?? '')); $contact = trim(($c['phone'] ?? '') . ($c['email'] ?? '')); return $name !== '' || $contact !== ''; })); // Sort by last name, first name, then type (Primary before Second) usort($contacts, function ($a, $b) { $la = strtolower($a['lastname'] ?? ''); $lb = strtolower($b['lastname'] ?? ''); if ($la !== $lb) return $la <=> $lb; $fa = strtolower($a['firstname'] ?? ''); $fb = strtolower($b['firstname'] ?? ''); if ($fa !== $fb) return $fa <=> $fb; // Primary before Second $ta = ($a['type'] ?? '') === 'Primary Parent' ? 0 : 1; $tb = ($b['type'] ?? '') === 'Primary Parent' ? 0 : 1; return $ta <=> $tb; }); return view('whatsapp/parent_contacts', [ 'contacts' => array_map(function ($c) { // The view expects 'phone' and 'email' directly; keep as-is return [ 'firstname' => $c['firstname'], 'lastname' => $c['lastname'], 'email' => $c['email'], 'phone' => $c['phone'], 'type' => $c['type'], ]; }, $contacts), 'schoolYear' => $schoolYear, 'semester' => $semester, ]); } public function parentContactsByClass() { $schoolYear = $this->schoolYear ?? (string)($this->request->getGet('school_year') ?? ''); // This page is year-only: ignore semester filters. $semester = ''; // 1) Get sections actually used this term from student_class, then name from classSection // 1) Get distinct class_section_id actually used this term from student_class $csb = $this->db->table('student_class sc') ->select('sc.class_section_id, MAX(sc.school_year) AS school_year'); if ($schoolYear !== '') $csb->where('sc.school_year', $schoolYear); $sections = $csb->groupBy('sc.class_section_id') ->orderBy('sc.class_section_id', 'ASC') ->get()->getResultArray(); // Build list of IDs $sectionIds = array_values(array_filter(array_unique(array_map( fn($r) => (int)($r['class_section_id'] ?? 0), $sections )))); // 2) Fetch names from classSection in a separate query and map by id $namesById = []; if (!empty($sectionIds)) { $nb = $this->db->table('classSection') // IMPORTANT: exact table name/case ->select('class_section_id, class_section_name') ->whereIn('class_section_id', $sectionIds) ->get()->getResultArray(); foreach ($nb as $row) { $namesById[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? ''); } } // 3) Build $classSections with reliable names $classSections = []; foreach ($sections as $s) { $sid = (int)($s['class_section_id'] ?? 0); if (!$sid) continue; $classSections[$sid] = [ 'class_section_id' => $sid, 'class_section_name' => $namesById[$sid] ?? ('Section ' . $sid), 'semester' => $semester, 'school_year' => (string)($s['school_year'] ?? ''), 'description' => '', 'main_teachers' => [], 'teacher_assistants' => [], 'parents' => [], ]; } // 2) Parents per class: // sc → students s → users u (primary parent) // parents sp on (sp.firstparent_id = u.id) for second parent (same term) $pb = $this->db->table('student_class sc'); $pb->select(" sc.class_section_id, u.id AS primary_id, u.firstname AS primary_firstname, u.lastname AS primary_lastname, u.email AS primary_email, u.cellphone AS primary_phone, sp.id AS second_id, sp.secondparent_firstname AS second_firstname, sp.secondparent_lastname AS second_lastname, sp.secondparent_email AS second_email, sp.secondparent_phone AS second_phone "); // IMPORTANT: join through students table (NOT sc.parent_id) $pb->join('students s', 's.id = sc.student_id', 'inner'); // adjust if your table is named `student` (singular) $pb->join('users u', 'u.id = s.parent_id', 'inner'); // primary parent lives on students.parent_id // Second parent row for the same term (allow NULL/'' semester if you sometimes omit it) $pb->join( 'parents sp', "sp.firstparent_id = u.id AND sp.school_year = sc.school_year", 'left' ); if ($schoolYear !== '') $pb->where('sc.school_year', $schoolYear); $pb->orderBy('sc.class_section_id', 'ASC') ->orderBy('u.lastname', 'ASC') ->orderBy('u.firstname', 'ASC'); $rows = $pb->get()->getResultArray(); // 3) De-duplicate a primary parent within the same class section $seen = []; foreach ($rows as $r) { $sid = (int)($r['class_section_id'] ?? 0); $pid = (int)($r['primary_id'] ?? 0); if (!$sid || !$pid || !isset($classSections[$sid])) continue; $key = $sid . ':' . $pid; if (isset($seen[$key])) continue; $seen[$key] = true; $classSections[$sid]['parents'][] = [ 'class_section_id' => $sid, 'primary_id' => $pid, 'primary_name' => trim(($r['primary_lastname'] ?? '') . ', ' . ($r['primary_firstname'] ?? '')), 'primary_phone' => (string)($r['primary_phone'] ?? ''), 'primary_email' => (string)($r['primary_email'] ?? ''), 'second_id' => (int)($r['second_id'] ?? 0), 'second_name' => trim(($r['second_lastname'] ?? '') . ', ' . ($r['second_firstname'] ?? '')), 'second_phone' => (string)($r['second_phone'] ?? ''), 'second_email' => (string)($r['second_email'] ?? ''), // Use null to indicate "unknown/not recorded" so UI can show — (like attendance) 'primary_member' => null, 'second_member' => null, ]; } // Annotate membership flags from whatsapp_group_memberships try { $sectionIds = array_keys($classSections); $m = $this->membershipModel->getBySectionsAndTerm($sectionIds, $schoolYear, $semester); foreach ($classSections as $sid => &$sec) { foreach ($sec['parents'] as &$p) { $pk1 = sprintf('%d:primary:%d', (int)$p['class_section_id'], (int)$p['primary_id']); if (isset($m[$pk1])) { $p['primary_member'] = (int)($m[$pk1]['is_member'] ?? 0); } $secondId = (int)($p['second_id'] ?? 0); if ($secondId > 0) { $pk2 = sprintf('%d:second:%d', (int)$p['class_section_id'], $secondId); if (isset($m[$pk2])) { $p['second_member'] = (int)($m[$pk2]['is_member'] ?? 0); } } } unset($p); } unset($sec); } catch (\Throwable $e) { // ignore membership annotation errors } // 4) (Optional) Teachers, if you keep them in teacher_class try { $tb = $this->db->table('teacher_class tc') ->select('tc.class_section_id, tc.position, u.firstname, u.lastname') ->join('users u', 'u.id = tc.teacher_id', 'left'); if ($schoolYear !== '') $tb->where('tc.school_year', $schoolYear); $teachers = $tb->get()->getResultArray(); foreach ($teachers as $t) { $sid = (int)($t['class_section_id'] ?? 0); if (!isset($classSections[$sid])) continue; $name = trim(($t['lastname'] ?? '') . ', ' . ($t['firstname'] ?? '')); $pos = strtolower((string)($t['position'] ?? 'main')); if ($pos === 'ta' || $pos === 'assistant') { $classSections[$sid]['teacher_assistants'][] = $name; } else { $classSections[$sid]['main_teachers'][] = $name; } } } catch (\Throwable $e) { // ignore if table not present } foreach ($classSections as &$sec) { $sec['main_teachers'] = array_values(array_filter($sec['main_teachers'] ?? [])); $sec['teacher_assistants'] = array_values(array_filter($sec['teacher_assistants'] ?? [])); $sec['parents'] = array_values($sec['parents'] ?? []); } unset($sec); return view('whatsapp/parent_contacts_by_class', [ 'classSections' => array_values($classSections), ]); } /** * POST: Update WhatsApp group membership flags for a class/parent(s). * Accepts fields: * - class_section_id (int) * - primary_id (int, optional) * - second_id (int, optional) * - primary_member ('on' or '1' if checked) * - second_member ('on' or '1' if checked) */ public function updateMembership() { // Only accept POST for writes if (strtoupper($this->request->getMethod()) !== 'POST') { return $this->response->setStatusCode(405)->setBody('Method Not Allowed'); } // Read POSTed values explicitly (avoid GET bleed-through) $sid = (int) ($this->request->getPost('class_section_id') ?? 0); $primaryId = (int) ($this->request->getPost('primary_id') ?? 0); $secondId = (int) ($this->request->getPost('second_id') ?? 0); // Handle checkbox values that may arrive as string or array (if duplicated inputs) $isChecked = static function ($v): bool { if (is_array($v)) { foreach ($v as $one) { if ($one === '1' || strtolower((string)$one) === 'on') return true; } return false; } return ($v === '1' || strtolower((string)$v) === 'on'); }; $primaryValueRaw = $this->request->getPost('primary_member'); $secondValueRaw = $this->request->getPost('second_member'); // Debug trace (temporary): log incoming payload to help diagnose saving issues try { log_message('error', 'DBG updateMembership: method={m} sid={sid} pid={pid} sec={sec} pRaw={p} sRaw={sr}', [ 'm' => $this->request->getMethod(), 'sid' => $sid, 'pid' => $primaryId, 'sec' => $secondId, 'p' => json_encode($primaryValueRaw), 'sr' => json_encode($secondValueRaw), ]); } catch (\Throwable $e) { // ignore } if ($sid <= 0) { return redirect()->back()->with('error', 'Invalid class section.'); } $schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear ?? ''); $semester = (string) ($this->request->getPost('semester') ?? $this->semester ?? ''); $schoolYear = trim($schoolYear); $semester = trim($semester); // Identify the actor (if available) $verifiedBy = null; try { $verifiedBy = (int) (session('id') ?? 0) ?: null; } catch (\Throwable $e) { $verifiedBy = null; } // Ensure the target table exists (prefer tableExists if available) $tableExists = null; try { if (method_exists($this->db, 'tableExists')) { $tableExists = $this->db->tableExists('whatsapp_group_memberships'); } } catch (\Throwable $e) { $tableExists = null; // fall back to getFieldNames below } if ($tableExists === null) { try { $this->db->getFieldNames('whatsapp_group_memberships'); $tableExists = true; } catch (\Throwable $e) { $tableExists = false; } } if (!$tableExists) { $msg = 'Membership table missing. Please run migrations.'; log_message('error', 'updateMembership aborted: {msg}', ['msg' => $msg]); if ($this->request->isAJAX()) { return $this->response->setStatusCode(500)->setJSON([ 'status' => 'error', 'error' => $msg, ]); } return redirect()->back()->with('error', $msg); } try { $saved = []; if ($primaryId > 0) { $isMember = $isChecked($primaryValueRaw); $id = $this->membershipModel->upsertMembership($sid, $schoolYear, $semester, 'primary', $primaryId, $isMember, $verifiedBy); if ($id <= 0) { // Log DB error details if available try { $dberr = isset($this->membershipModel->db) ? $this->membershipModel->db->error() : []; log_message('error', 'Failed to upsert primary membership: sid={sid} pid={pid} term={y}/{s} err={e}', [ 'sid' => $sid, 'pid' => $primaryId, 'y' => $schoolYear, 's' => $semester, 'e' => json_encode($dberr) ]); } catch (\Throwable $e) {} } $saved[] = 'primary=' . $primaryId . ':' . ($isMember ? '1' : '0'); } if ($secondId > 0) { $isMember = $isChecked($secondValueRaw); $id = $this->membershipModel->upsertMembership($sid, $schoolYear, $semester, 'second', $secondId, $isMember, $verifiedBy); if ($id <= 0) { try { $dberr = isset($this->membershipModel->db) ? $this->membershipModel->db->error() : []; log_message('error', 'Failed to upsert second membership: sid={sid} sec={sec} term={y}/{s} err={e}', [ 'sid' => $sid, 'sec' => $secondId, 'y' => $schoolYear, 's' => $semester, 'e' => json_encode($dberr) ]); } catch (\Throwable $e) {} } $saved[] = 'second=' . $secondId . ':' . ($isMember ? '1' : '0'); } } catch (\Throwable $e) { log_message('error', 'Failed to update WhatsApp membership: {err}', ['err' => $e->getMessage()]); if ($this->request->isAJAX()) { return $this->response->setStatusCode(500)->setJSON([ 'status' => 'error', 'error' => 'Failed to save membership.', ]); } return redirect()->back()->with('error', 'Failed to save membership.'); } if (empty($saved)) { if ($this->request->isAJAX()) { return $this->response->setJSON([ 'status' => 'noop', 'message' => 'Nothing to save (missing parent IDs).', ]); } return redirect()->back()->with('error', 'Nothing to save (missing parent IDs).'); } if ($this->request->isAJAX()) { return $this->response->setJSON([ 'status' => 'ok', 'saved' => $saved, 'term' => ['school_year' => $schoolYear, 'semester' => $semester], ]); } return redirect()->back()->with('message', 'Membership saved: ' . implode(', ', $saved)); } /** * Build initial lists for the page: * - ALL sections from student_class for current term * - ALL parents: primaries (users who have students in term) + second parents (parents table) */ private function prepareIndexData(string $schoolYear, string $semester): array { $schoolYear = (string) $schoolYear; $semester = trim((string) $semester); [$yearAliases, $normalizedYear, $startYear] = $this->syAliases($schoolYear); $semAliases = $semester !== '' ? $this->semAliases($semester) : []; $limitToSemester = $semester !== '' && $this->hasRosterForTerm($schoolYear, $semester); // ----------------------------- // Sections list (primary source: student_class) // ----------------------------- $secQ = $this->db->table('student_class sc') ->select('DISTINCT sc.class_section_id', false); // school_year filter with aliases $secQ->groupStart() ->whereIn('sc.school_year', $yearAliases) ->orWhere("REPLACE(REPLACE(sc.school_year,'/','-'),'–','-')", $normalizedYear) ->orWhere('sc.school_year', (string)$startYear) ->groupEnd(); $secRows = $secQ->orderBy('sc.class_section_id', 'ASC')->get()->getResultArray(); // Fallback: if student_class is empty for the term, try enrollments if (empty($secRows)) { $secQ = $this->db->table('enrollments e') ->select('DISTINCT e.class_section_id', false); $secQ->groupStart() ->whereIn('e.school_year', $yearAliases) ->orWhere("REPLACE(REPLACE(e.school_year,'/','-'),'–','-')", $normalizedYear) ->orWhere('e.school_year', (string)$startYear) ->groupEnd(); if ($limitToSemester && !empty($semAliases)) { $secQ->groupStart(); foreach ($semAliases as $i => $s) { $i === 0 ? $secQ->where('LOWER(e.semester)', strtolower($s)) : $secQ->orWhere('LOWER(e.semester)', strtolower($s)); } $secQ->orWhere('e.semester', $semester)->groupEnd(); } elseif ($limitToSemester) { $secQ->where('e.semester', $semester); } $secRows = $secQ->orderBy('e.class_section_id', 'ASC')->get()->getResultArray(); } $sections = []; if (!empty($secRows)) { foreach ($secRows as $r) { $cid = (int)($r['class_section_id'] ?? 0); if (!$cid) continue; // Try to get a friendly name from links; fallback to "Section {id}" $linkRow = $this->linkModel->getLinkForSection($cid, $schoolYear, $semester); $name = $this->classSectionModel->getClassSectionNameBySectionId($cid); $sections[] = [ 'class_section_id' => $cid, 'class_section_name' => $name, ]; } } // ----------------------------- // Parents list (primaries + secondaries) for the term // primary source: student_class->students->users // ----------------------------- $pidQ = $this->db->table('student_class sc') ->select('DISTINCT s.parent_id', false) ->join('students s', 's.id = sc.student_id', 'left'); $pidQ->groupStart() ->whereIn('sc.school_year', $yearAliases) ->orWhere("REPLACE(REPLACE(sc.school_year,'/','-'),'–','-')", $normalizedYear) ->orWhere('sc.school_year', (string)$startYear) ->groupEnd(); $pidQ->where('s.parent_id IS NOT NULL', null, false); $primaryIdRows = $pidQ->get()->getResultArray(); // Fallback: if no rows via student_class, try enrollments if (empty($primaryIdRows)) { $pidQ = $this->db->table('enrollments e') ->select('DISTINCT s.parent_id', false) ->join('students s', 's.id = e.student_id', 'left'); $pidQ->groupStart() ->whereIn('e.school_year', $yearAliases) ->orWhere("REPLACE(REPLACE(e.school_year,'/','-'),'–','-')", $normalizedYear) ->orWhere('e.school_year', (string)$startYear) ->groupEnd(); if ($limitToSemester && !empty($semAliases)) { $pidQ->groupStart(); foreach ($semAliases as $i => $s) { $i === 0 ? $pidQ->where('LOWER(e.semester)', strtolower($s)) : $pidQ->orWhere('LOWER(e.semester)', strtolower($s)); } $pidQ->orWhere('e.semester', $semester)->groupEnd(); } elseif ($limitToSemester) { $pidQ->where('e.semester', $semester); } $pidQ->where('s.parent_id IS NOT NULL', null, false); $primaryIdRows = $pidQ->get()->getResultArray(); } $primaryIds = []; foreach ($primaryIdRows as $r) { $pid = (int)($r['parent_id'] ?? 0); if ($pid) $primaryIds[$pid] = true; } $primaryIds = array_keys($primaryIds); $primaryProfiles = []; if (!empty($primaryIds)) { $primRows = $this->db->table('users') ->select('id, firstname, lastname, email') ->whereIn('id', $primaryIds) ->get()->getResultArray(); foreach ($primRows as $u) { $uid = (int)($u['id'] ?? 0); if (!$uid) continue; $primaryProfiles[$uid] = [ 'id' => $uid, 'firstname' => (string)($u['firstname'] ?? ''), 'lastname' => (string)($u['lastname'] ?? ''), 'email' => (string)($u['email'] ?? ''), 'source' => 'users', ]; } } // Second parents (allow rows without semester; we’re already term-bound by primaryIds) $secondaryProfiles = []; if (!empty($primaryIds)) { $secRows = $this->db->table('parents') ->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone') ->whereIn('firstparent_id', $primaryIds) ->orderBy('updated_at', 'DESC') // in case of duplicates, latest wins ->get()->getResultArray(); foreach ($secRows as $r) { $primaryId = (int) $r['firstparent_id']; $secondaryProfiles[$primaryId] = [ 'id' => (int) $r['id'], 'firstname' => (string) $r['secondparent_firstname'], 'lastname' => (string) $r['secondparent_lastname'], 'email' => (string) $r['secondparent_email'], 'phone' => (string) $r['secondparent_phone'], 'source' => 'parents', ]; } } // Merge + sort $parents = array_values($primaryProfiles); foreach ($secondaryProfiles as $sp) $parents[] = $sp; usort($parents, function ($a, $b) { $ln = strcasecmp($a['lastname'] ?? '', $b['lastname'] ?? ''); if ($ln !== 0) return $ln; $fn = strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? ''); if ($fn !== 0) return $fn; $sa = ($a['source'] ?? '') === 'users' ? 0 : 1; $sb = ($b['source'] ?? '') === 'users' ? 0 : 1; return $sa <=> $sb; }); return [$parents, $sections]; } /** School-year aliases like 2025-2026, 2025/2026, 2025–2026, 2025-26, 2025 */ private function syAliases(string $schoolYear): array { $trim = trim($schoolYear); if (preg_match('/\b(20\d{2})\b/', $trim, $m)) { $start = (int)$m[1]; } else { $start = (int)date('Y'); } $end = $start + 1; $normalized = "{$start}-{$end}"; $aliases = [ $normalized, "{$start}/{$end}", "{$start}–{$end}", // en dash sprintf('%d-%02d', $start, $end % 100), // 2025-26 (string)$start, // some schemas store only start year ]; return [array_values(array_unique($aliases)), $normalized, $start]; } /** Semester aliases */ private function semAliases(string $semester): array { $s = strtolower(trim($semester)); if ($s === 'fall') return ['fall', 'sem1', 'semester 1', '1', 'f']; if ($s === 'spring') return ['spring', 'sem2', 'semester 2', '2', 's']; if ($s === 'summer') return ['summer', 'sem3', '3']; return [$s]; } /** * Returns true if student_class has any rows for the given school year. * The semester argument is ignored because assignments are tracked per year. */ private function hasRosterForTerm(string $schoolYear, string $semester): bool { $year = trim((string)$schoolYear); if ($year === '') { return false; } if (array_key_exists($year, $this->rosterPresenceCache)) { return $this->rosterPresenceCache[$year]; } $cnt = $this->db->table('student_class') ->where('school_year', $year) ->countAllResults(); $this->rosterPresenceCache[$year] = $cnt > 0; return $this->rosterPresenceCache[$year]; } /** * Parents mode: * - pickedId can be a primary (users.id) or a secondary (parents.id) * - resolve to primary -> students -> class_section_ids (from student_class) * - collect non-empty links for those sections * - return one bundle (emails = primary + second-parent if present) */ private function bundleForSingleParent(int $pickedId, array $linkBySection): ?array { // Determine if pickedId is primary $isPrimary = (bool) $this->db->table('users')->select('id')->where('id', $pickedId)->get()->getFirstRow(); $primaryId = $pickedId; if (!$isPrimary) { // pickedId is likely a second-parent; map to firstparent_id $map = $this->db->table('parents') ->select('firstparent_id, id') ->where('id', $pickedId) ->get()->getFirstRow('array'); if (!$map || empty($map['firstparent_id'])) { return null; } $primaryId = (int) $map['firstparent_id']; } // Primary profile $p = $this->db->table('users')->select('id, firstname, lastname, email')->where('id', $primaryId)->get()->getFirstRow('array'); if (!$p) return null; // Optional second-parent profile $sec = $this->db->table('parents') ->select('id, secondparent_firstname, secondparent_lastname, secondparent_email') ->where('firstparent_id', $primaryId) ->where('id >', 0) ->get()->getFirstRow('array'); // Sections for this primary via their students (term-scoped) $limitToSemester = $this->hasRosterForTerm($this->schoolYear, $this->semester); $secRows = $this->db->table('student_class sc') ->select('DISTINCT sc.class_section_id', false) ->join('students s', 's.id = sc.student_id', 'left') ->where('sc.school_year', $this->schoolYear); $secRows = $secRows->where('s.parent_id', $primaryId) ->get()->getResultArray(); $sections = []; $linksSet = []; foreach ($secRows as $r) { $cid = (int) ($r['class_section_id'] ?? 0); if (!$cid) continue; $link = (string) ($linkBySection[$cid]['invite_link'] ?? ''); if ($link === '') continue; $sections[] = [ 'class_section_id' => $cid, 'class_section_name' => (string) ($linkBySection[$cid]['class_section_name'] ?? ('Section ' . $cid)), 'invite_link' => $link, ]; $linksSet[$link] = true; } if (empty($sections)) return null; $emailsSet = []; if (!empty($p['email'])) $emailsSet[$p['email']] = true; if (!empty($sec['secondparent_email'])) $emailsSet[$sec['secondparent_email']] = true; if (empty($emailsSet)) return null; return [ 'parent' => [ 'id' => (int) $p['id'], 'firstname' => (string) ($p['firstname'] ?? ''), 'lastname' => (string) ($p['lastname'] ?? ''), 'email' => (string) ($p['email'] ?? ''), 'source' => 'users', ], 'secondary_parents' => $sec && !empty($sec['id']) ? [[ 'id' => (int) $sec['id'], 'firstname' => (string) ($sec['secondparent_firstname'] ?? ''), 'lastname' => (string) ($sec['secondparent_lastname'] ?? ''), 'email' => (string) ($sec['secondparent_email'] ?? ''), 'source' => 'parents', ]] : [], 'sections' => $sections, 'links' => array_keys($linksSet), 'emails' => array_keys($emailsSet), ]; } /** * Class mode: * - Given classSectionId, find students (student_class) for the term * - Map to primaries (users) and second-parents (parents) * - Return one bundle **per primary parent** (so each parent gets their own email) */ private function bundleForSingleClass(int $classSectionId, array $linkBySection): array { $link = (string) ($linkBySection[$classSectionId]['invite_link'] ?? ''); if ($link === '') return []; // no link => skip // Students in this class $stuRows = $this->db->table('student_class') ->select('student_id') ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->where('class_section_id', $classSectionId) ->get()->getResultArray(); if (empty($stuRows)) return []; $studentIds = array_values(array_unique(array_map(fn($r) => (int) $r['student_id'], $stuRows))); // Map students -> primary parent id $pRows = $this->db->table('students') ->select('id, parent_id') ->whereIn('id', $studentIds) ->get()->getResultArray(); $parentIds = []; foreach ($pRows as $r) { $pid = (int) ($r['parent_id'] ?? 0); if ($pid) $parentIds[$pid] = true; } $parentIds = array_keys($parentIds); if (empty($parentIds)) return []; // Load primary parent profiles $primRows = $this->db->table('users') ->select('id, firstname, lastname, email') ->whereIn('id', $parentIds) ->get()->getResultArray(); $profiles = []; foreach ($primRows as $u) { $uid = (int) $u['id']; $profiles[$uid] = [ 'id' => $uid, 'firstname' => (string) ($u['firstname'] ?? ''), 'lastname' => (string) ($u['lastname'] ?? ''), 'email' => (string) ($u['email'] ?? ''), 'source' => 'users', ]; } // Second-parents for these primaries $secRows = $this->db->table('parents') ->select('firstparent_id, id, secondparent_firstname, secondparent_lastname, secondparent_email') ->where('id >', 0) ->whereIn('firstparent_id', $parentIds) ->get()->getResultArray(); $secByPrimary = []; foreach ($secRows as $r) { $fp = (int) $r['firstparent_id']; $secByPrimary[$fp] = [ 'id' => (int) ($r['id'] ?? 0), 'firstname' => (string) ($r['secondparent_firstname'] ?? ''), 'lastname' => (string) ($r['secondparent_lastname'] ?? ''), 'email' => (string) ($r['secondparent_email'] ?? ''), 'source' => 'parents', ]; } // One bundle per primary parent $bundles = []; foreach ($parentIds as $pid) { $profile = $profiles[$pid] ?? null; if (!$profile) continue; $emails = []; if (!empty($profile['email'])) $emails[$profile['email']] = true; if (!empty($secByPrimary[$pid]['email'])) $emails[$secByPrimary[$pid]['email']] = true; if (empty($emails)) continue; $bundles[] = [ 'parent' => $profile, 'secondary_parents' => !empty($secByPrimary[$pid]['id']) ? [$secByPrimary[$pid]] : [], 'sections' => [[ 'class_section_id' => $classSectionId, 'class_section_name' => (string) ($linkBySection[$classSectionId]['class_section_name'] ?? ('Section ' . $classSectionId)), 'invite_link' => $link, ]], 'links' => [$link], 'emails' => array_keys($emails), ]; } return $bundles; } /** * All mode: * - Iterate all distinct class_section_id in student_class (for the term) * - Reuse class bundles per section and flatten */ private function bundlesForAllParentsAllClasses(array $linkBySection): array { $secRows = $this->db->table('student_class') ->select('DISTINCT class_section_id', false) ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->orderBy('class_section_id', 'ASC') ->get()->getResultArray(); $bundles = []; foreach ($secRows as $r) { $cid = (int) ($r['class_section_id'] ?? 0); if (!$cid) continue; $classBundles = $this->bundleForSingleClass($cid, $linkBySection); foreach ($classBundles as $b) { $bundles[] = $b; } } return $bundles; } }