Files
alrahma_sunday_school/app/Controllers/View/WhatsappController.php
T
2026-02-10 22:11:06 -05:00

1330 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController; // Models
use App\Models\StudentModel;
use App\Models\ClassSectionModel;
use App\Models\UserModel;
use App\Models\EnrollmentModel;
use App\Models\ParentModel;
use App\Models\ConfigurationModel;
use App\Models\WhatsappGroupLinkModel;
use App\Models\WhatsappInviteLogModel;
use App\Models\WhatsappGroupMembershipModel;
use CodeIgniter\Events\Events;
/**
* WhatsApp invites controller — rewritten to:
* - Build initial lists from student_class (sections) and users/parents (parents)
* - Parents mode: given a parent id, find their students -> 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<int> (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; were 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, 20252026, 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;
}
}