From 3bf4c687dafa5b9a306a03c40c925655b816216c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 16:31:35 -0400 Subject: [PATCH] add whatsup logic --- app/Events/WhatsappInvitesSend.php | 16 + .../Api/Messaging/WhatsappController.php | 156 ++ .../Whatsapp/WhatsappInviteSendRequest.php | 26 + .../Whatsapp/WhatsappLinkIndexRequest.php | 36 + .../Whatsapp/WhatsappLinkStoreRequest.php | 25 + .../Whatsapp/WhatsappLinkUpdateRequest.php | 25 + .../WhatsappMembershipUpdateRequest.php | 26 + .../WhatsappParentContactsByClassRequest.php | 21 + .../WhatsappParentContactsRequest.php | 21 + .../WhatsappClassSectionContactResource.php | 22 + .../WhatsappClassSectionParentResource.php | 25 + .../Whatsapp/WhatsappGroupLinkCollection.php | 27 + .../Whatsapp/WhatsappGroupLinkResource.php | 23 + .../WhatsappParentContactResource.php | 19 + .../Whatsapp/WhatsappContactService.php | 266 ++++ .../Whatsapp/WhatsappContextService.php | 102 ++ .../Whatsapp/WhatsappInviteBundleService.php | 347 +++++ .../WhatsappInviteNotificationService.php | 129 ++ .../Whatsapp/WhatsappInviteService.php | 82 + app/Services/Whatsapp/WhatsappLinkService.php | 125 ++ .../Whatsapp/WhatsappMembershipService.php | 106 ++ app/old/WhatsappController.php | 1329 ----------------- routes/api.php | 12 + test-output.txt | 292 ++++ .../Whatsapp/WhatsappInviteControllerTest.php | 214 +++ .../Whatsapp/WhatsappLinkControllerTest.php | 184 +++ .../Whatsapp/WhatsappContactServiceTest.php | 57 + .../Whatsapp/WhatsappContextServiceTest.php | 41 + .../WhatsappInviteBundleServiceTest.php | 110 ++ .../WhatsappInviteNotificationServiceTest.php | 39 + .../Whatsapp/WhatsappInviteServiceTest.php | 111 ++ .../Whatsapp/WhatsappLinkServiceTest.php | 66 + .../WhatsappMembershipServiceTest.php | 49 + 33 files changed, 2800 insertions(+), 1329 deletions(-) create mode 100644 app/Events/WhatsappInvitesSend.php create mode 100644 app/Http/Controllers/Api/Messaging/WhatsappController.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappInviteSendRequest.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappLinkIndexRequest.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappLinkStoreRequest.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappLinkUpdateRequest.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappMembershipUpdateRequest.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappParentContactsByClassRequest.php create mode 100644 app/Http/Requests/Whatsapp/WhatsappParentContactsRequest.php create mode 100644 app/Http/Resources/Whatsapp/WhatsappClassSectionContactResource.php create mode 100644 app/Http/Resources/Whatsapp/WhatsappClassSectionParentResource.php create mode 100644 app/Http/Resources/Whatsapp/WhatsappGroupLinkCollection.php create mode 100644 app/Http/Resources/Whatsapp/WhatsappGroupLinkResource.php create mode 100644 app/Http/Resources/Whatsapp/WhatsappParentContactResource.php create mode 100644 app/Services/Whatsapp/WhatsappContactService.php create mode 100644 app/Services/Whatsapp/WhatsappContextService.php create mode 100644 app/Services/Whatsapp/WhatsappInviteBundleService.php create mode 100644 app/Services/Whatsapp/WhatsappInviteNotificationService.php create mode 100644 app/Services/Whatsapp/WhatsappInviteService.php create mode 100644 app/Services/Whatsapp/WhatsappLinkService.php create mode 100644 app/Services/Whatsapp/WhatsappMembershipService.php delete mode 100644 app/old/WhatsappController.php create mode 100644 test-output.txt create mode 100644 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php create mode 100644 tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappContextServiceTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappInviteNotificationServiceTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappLinkServiceTest.php create mode 100644 tests/Unit/Services/Whatsapp/WhatsappMembershipServiceTest.php diff --git a/app/Events/WhatsappInvitesSend.php b/app/Events/WhatsappInvitesSend.php new file mode 100644 index 00000000..af1cf788 --- /dev/null +++ b/app/Events/WhatsappInvitesSend.php @@ -0,0 +1,16 @@ +linkService->paginate($request->validated()); + $collection = new WhatsappGroupLinkCollection($links); + + return $this->success([ + 'links' => $collection->toArray($request), + 'meta' => $collection->with($request)['meta'], + ]); + } + + public function show(int $linkId): JsonResponse + { + $link = $this->linkService->findOrFail($linkId); + + return $this->success([ + 'link' => new WhatsappGroupLinkResource($link), + ]); + } + + public function store(WhatsappLinkStoreRequest $request): JsonResponse + { + $link = $this->linkService->upsert($request->validated()); + + $code = $link->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK; + + return $this->success([ + 'link' => new WhatsappGroupLinkResource($link), + ], 'WhatsApp group link saved.', $code); + } + + public function update(WhatsappLinkUpdateRequest $request, int $linkId): JsonResponse + { + $link = $this->linkService->update($linkId, $request->validated()); + + return $this->success([ + 'link' => new WhatsappGroupLinkResource($link), + ], 'WhatsApp group link updated.'); + } + + public function destroy(int $linkId): JsonResponse + { + $this->linkService->delete($linkId); + + return $this->success(null, 'WhatsApp group link deleted.'); + } + + public function parentContacts(WhatsappParentContactsRequest $request): JsonResponse + { + $payload = $request->validated(); + $schoolYear = $this->context->schoolYear($payload['school_year'] ?? null); + $semester = array_key_exists('semester', $payload) + ? (string) ($payload['semester'] ?? '') + : $this->context->semester(); + + $contacts = $this->contactService->listParentContacts($schoolYear, $semester); + + return $this->success([ + 'contacts' => WhatsappParentContactResource::collection($contacts), + ]); + } + + public function parentContactsByClass(WhatsappParentContactsByClassRequest $request): JsonResponse + { + $payload = $request->validated(); + $schoolYear = $this->context->schoolYear($payload['school_year'] ?? null); + $semester = array_key_exists('semester', $payload) + ? (string) ($payload['semester'] ?? '') + : $this->context->semester(); + + $sections = $this->contactService->listParentContactsByClass($schoolYear, $semester); + + return $this->success([ + 'class_sections' => WhatsappClassSectionContactResource::collection($sections), + ]); + } + + public function sendInvites(WhatsappInviteSendRequest $request): JsonResponse + { + try { + $result = $this->inviteService->send($request->validated()); + } catch (\Throwable $e) { + Log::error('WhatsApp invite send failed: ' . $e->getMessage()); + return $this->error('Invite processing failed.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + if (!($result['ok'] ?? false)) { + return $this->error($result['message'] ?? 'Invite processing failed.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success([ + 'triggered' => (int) ($result['triggered'] ?? 0), + ], 'Invite processing started.'); + } + + public function updateMembership(WhatsappMembershipUpdateRequest $request): JsonResponse + { + try { + $userId = (int) (auth()->id() ?? 0) ?: null; + $result = $this->membershipService->updateMembership($request->validated(), $userId); + } catch (\Throwable $e) { + Log::error('WhatsApp membership update failed: ' . $e->getMessage()); + return $this->error('Failed to save membership.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + if (!($result['ok'] ?? false)) { + $code = str_contains((string) ($result['message'] ?? ''), 'missing') + ? Response::HTTP_INTERNAL_SERVER_ERROR + : Response::HTTP_UNPROCESSABLE_ENTITY; + + return $this->error($result['message'] ?? 'Failed to save membership.', $code); + } + + return $this->success([ + 'saved' => $result['saved'] ?? [], + 'term' => $result['term'] ?? [], + ], 'Membership saved.'); + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappInviteSendRequest.php b/app/Http/Requests/Whatsapp/WhatsappInviteSendRequest.php new file mode 100644 index 00000000..b472e951 --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappInviteSendRequest.php @@ -0,0 +1,26 @@ +check(); + } + + public function rules(): array + { + return [ + 'mode' => ['required', 'string', Rule::in(['parents', 'class', 'all'])], + 'class_section_id' => ['required_if:mode,class', 'integer', 'min:1'], + 'parent_ids' => ['required_if:mode,parents', 'array'], + 'parent_ids.*' => ['integer', 'min:1'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappLinkIndexRequest.php b/app/Http/Requests/Whatsapp/WhatsappLinkIndexRequest.php new file mode 100644 index 00000000..4776be52 --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappLinkIndexRequest.php @@ -0,0 +1,36 @@ +check(); + } + + public function rules(): array + { + return [ + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'class_section_id' => ['nullable', 'integer', 'min:1'], + 'active' => ['nullable', 'boolean'], + 'search' => ['nullable', 'string', 'max:2000'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + 'page' => ['nullable', 'integer', 'min:1'], + 'sort_by' => ['nullable', 'string', Rule::in([ + 'class_section_id', + 'class_section_name', + 'school_year', + 'semester', + 'active', + 'updated_at', + ])], + 'sort_dir' => ['nullable', 'string', Rule::in(['asc', 'desc'])], + ]; + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappLinkStoreRequest.php b/app/Http/Requests/Whatsapp/WhatsappLinkStoreRequest.php new file mode 100644 index 00000000..fe592b54 --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappLinkStoreRequest.php @@ -0,0 +1,25 @@ +check(); + } + + public function rules(): array + { + return [ + 'class_section_id' => ['required', 'integer', 'min:1'], + 'class_section_name' => ['nullable', 'string', 'max:255'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'invite_link' => ['required', 'string', 'max:2000'], + 'active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappLinkUpdateRequest.php b/app/Http/Requests/Whatsapp/WhatsappLinkUpdateRequest.php new file mode 100644 index 00000000..8f7f980a --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappLinkUpdateRequest.php @@ -0,0 +1,25 @@ +check(); + } + + public function rules(): array + { + return [ + 'class_section_id' => ['sometimes', 'integer', 'min:1'], + 'class_section_name' => ['nullable', 'string', 'max:255'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'invite_link' => ['nullable', 'string', 'max:2000'], + 'active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappMembershipUpdateRequest.php b/app/Http/Requests/Whatsapp/WhatsappMembershipUpdateRequest.php new file mode 100644 index 00000000..6201267a --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappMembershipUpdateRequest.php @@ -0,0 +1,26 @@ +check(); + } + + public function rules(): array + { + return [ + 'class_section_id' => ['required', 'integer', 'min:1'], + 'primary_id' => ['nullable', 'integer', 'min:1'], + 'second_id' => ['nullable', 'integer', 'min:1'], + 'primary_member' => ['nullable', 'boolean'], + 'second_member' => ['nullable', 'boolean'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappParentContactsByClassRequest.php b/app/Http/Requests/Whatsapp/WhatsappParentContactsByClassRequest.php new file mode 100644 index 00000000..6c7a1e9b --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappParentContactsByClassRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Whatsapp/WhatsappParentContactsRequest.php b/app/Http/Requests/Whatsapp/WhatsappParentContactsRequest.php new file mode 100644 index 00000000..c7466638 --- /dev/null +++ b/app/Http/Requests/Whatsapp/WhatsappParentContactsRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Resources/Whatsapp/WhatsappClassSectionContactResource.php b/app/Http/Resources/Whatsapp/WhatsappClassSectionContactResource.php new file mode 100644 index 00000000..c45cdd56 --- /dev/null +++ b/app/Http/Resources/Whatsapp/WhatsappClassSectionContactResource.php @@ -0,0 +1,22 @@ + (int) ($this['class_section_id'] ?? 0), + 'class_section_name' => (string) ($this['class_section_name'] ?? ''), + 'semester' => (string) ($this['semester'] ?? ''), + 'school_year' => (string) ($this['school_year'] ?? ''), + 'description' => (string) ($this['description'] ?? ''), + 'main_teachers' => array_values($this['main_teachers'] ?? []), + 'teacher_assistants' => array_values($this['teacher_assistants'] ?? []), + 'parents' => WhatsappClassSectionParentResource::collection($this['parents'] ?? []), + ]; + } +} diff --git a/app/Http/Resources/Whatsapp/WhatsappClassSectionParentResource.php b/app/Http/Resources/Whatsapp/WhatsappClassSectionParentResource.php new file mode 100644 index 00000000..514ec7d8 --- /dev/null +++ b/app/Http/Resources/Whatsapp/WhatsappClassSectionParentResource.php @@ -0,0 +1,25 @@ + (int) ($this['class_section_id'] ?? 0), + 'primary_id' => (int) ($this['primary_id'] ?? 0), + 'primary_name' => (string) ($this['primary_name'] ?? ''), + 'primary_phone' => (string) ($this['primary_phone'] ?? ''), + 'primary_email' => (string) ($this['primary_email'] ?? ''), + 'second_id' => (int) ($this['second_id'] ?? 0), + 'second_name' => (string) ($this['second_name'] ?? ''), + 'second_phone' => (string) ($this['second_phone'] ?? ''), + 'second_email' => (string) ($this['second_email'] ?? ''), + 'primary_member' => $this['primary_member'] ?? null, + 'second_member' => $this['second_member'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Whatsapp/WhatsappGroupLinkCollection.php b/app/Http/Resources/Whatsapp/WhatsappGroupLinkCollection.php new file mode 100644 index 00000000..09b95516 --- /dev/null +++ b/app/Http/Resources/Whatsapp/WhatsappGroupLinkCollection.php @@ -0,0 +1,27 @@ + [ + 'total' => $this->resource->total() ?? null, + 'per_page' => $this->resource->perPage() ?? null, + 'current_page' => $this->resource->currentPage() ?? null, + 'last_page' => $this->resource->lastPage() ?? null, + ], + ]; + } +} diff --git a/app/Http/Resources/Whatsapp/WhatsappGroupLinkResource.php b/app/Http/Resources/Whatsapp/WhatsappGroupLinkResource.php new file mode 100644 index 00000000..9a14237f --- /dev/null +++ b/app/Http/Resources/Whatsapp/WhatsappGroupLinkResource.php @@ -0,0 +1,23 @@ + (int) ($this->id ?? 0), + 'class_section_id' => (int) ($this->class_section_id ?? 0), + 'class_section_name' => (string) ($this->class_section_name ?? ''), + 'school_year' => (string) ($this->school_year ?? ''), + 'semester' => (string) ($this->semester ?? ''), + 'invite_link' => (string) ($this->invite_link ?? ''), + 'active' => (bool) ($this->active ?? false), + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } +} diff --git a/app/Http/Resources/Whatsapp/WhatsappParentContactResource.php b/app/Http/Resources/Whatsapp/WhatsappParentContactResource.php new file mode 100644 index 00000000..9158a272 --- /dev/null +++ b/app/Http/Resources/Whatsapp/WhatsappParentContactResource.php @@ -0,0 +1,19 @@ + (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'email' => (string) ($this['email'] ?? ''), + 'phone' => (string) ($this['phone'] ?? ''), + 'type' => (string) ($this['type'] ?? ''), + ]; + } +} diff --git a/app/Services/Whatsapp/WhatsappContactService.php b/app/Services/Whatsapp/WhatsappContactService.php new file mode 100644 index 00000000..5947fd6d --- /dev/null +++ b/app/Services/Whatsapp/WhatsappContactService.php @@ -0,0 +1,266 @@ +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 + "); + $builder->leftJoin('users as u', 'u.id', '=', 'sp.firstparent_id'); + + if ($schoolYear !== '') { + $builder->where('sp.school_year', $schoolYear); + } + if ($semester !== '') { + $builder->where('sp.semester', $semester); + } + + $rows = $builder->get()->all(); + + $contacts = []; + foreach ($rows as $row) { + $r = (array) $row; + 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', + ]; + } + + $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', + ]; + } + } + + $contacts = array_values(array_filter($contacts, function ($c) { + $name = trim(($c['firstname'] ?? '') . ($c['lastname'] ?? '')); + $contact = trim(($c['phone'] ?? '') . ($c['email'] ?? '')); + return $name !== '' || $contact !== ''; + })); + + 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; + } + $ta = ($a['type'] ?? '') === 'Primary Parent' ? 0 : 1; + $tb = ($b['type'] ?? '') === 'Primary Parent' ? 0 : 1; + return $ta <=> $tb; + }); + + return $contacts; + } + + public function listParentContactsByClass(?string $schoolYear, ?string $semester): array + { + $schoolYear = trim((string) $schoolYear); + $semester = trim((string) $semester); + + $sections = DB::table('student_class as sc') + ->select('sc.class_section_id', DB::raw('MAX(sc.school_year) AS school_year')) + ->when($schoolYear !== '', fn ($q) => $q->where('sc.school_year', $schoolYear)) + ->groupBy('sc.class_section_id') + ->orderBy('sc.class_section_id', 'ASC') + ->get() + ->all(); + + $sectionIds = array_values(array_filter(array_unique(array_map( + fn ($r) => (int) ($r->class_section_id ?? 0), + $sections + )))); + + $namesById = []; + if (!empty($sectionIds)) { + $nameRows = DB::table('classSection') + ->select('class_section_id', 'class_section_name') + ->whereIn('class_section_id', $sectionIds) + ->get() + ->all(); + + foreach ($nameRows as $row) { + $namesById[(int) $row->class_section_id] = (string) ($row->class_section_name ?? ''); + } + } + + $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' => [], + ]; + } + + $rows = DB::table('student_class as sc') + ->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 + ") + ->join('students as s', 's.id', '=', 'sc.student_id') + ->join('users as u', 'u.id', '=', 's.parent_id') + ->leftJoin('parents as sp', function ($join) { + $join->on('sp.firstparent_id', '=', 'u.id') + ->on('sp.school_year', '=', 'sc.school_year'); + }) + ->when($schoolYear !== '', fn ($q) => $q->where('sc.school_year', $schoolYear)) + ->orderBy('sc.class_section_id', 'ASC') + ->orderBy('u.lastname', 'ASC') + ->orderBy('u.firstname', 'ASC') + ->get() + ->all(); + + $seen = []; + foreach ($rows as $row) { + $r = (array) $row; + $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'] ?? ''), + 'primary_member' => null, + 'second_member' => null, + ]; + } + + try { + $sectionIds = array_keys($classSections); + $membershipMap = WhatsappGroupMembership::getBySectionsAndTerm($sectionIds, $schoolYear, $semester); + foreach ($classSections as $sid => &$section) { + foreach ($section['parents'] as &$parent) { + $key = sprintf('%d:primary:%d', (int) $parent['class_section_id'], (int) $parent['primary_id']); + if (isset($membershipMap[$key])) { + $parent['primary_member'] = (int) ($membershipMap[$key]['is_member'] ?? 0); + } + + $secondId = (int) ($parent['second_id'] ?? 0); + if ($secondId > 0) { + $key2 = sprintf('%d:second:%d', (int) $parent['class_section_id'], $secondId); + if (isset($membershipMap[$key2])) { + $parent['second_member'] = (int) ($membershipMap[$key2]['is_member'] ?? 0); + } + } + } + unset($parent); + } + unset($section); + } catch (\Throwable $e) { + Log::warning('WhatsApp membership lookup failed: ' . $e->getMessage()); + } + + try { + $teachers = DB::table('teacher_class as tc') + ->select('tc.class_section_id', 'tc.position', 'u.firstname', 'u.lastname') + ->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id') + ->when($schoolYear !== '', fn ($q) => $q->where('tc.school_year', $schoolYear)) + ->get() + ->all(); + + foreach ($teachers as $row) { + $r = (array) $row; + $sid = (int) ($r['class_section_id'] ?? 0); + if (!isset($classSections[$sid])) { + continue; + } + $name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? '')); + $pos = strtolower((string) ($r['position'] ?? 'main')); + if ($pos === 'ta' || $pos === 'assistant') { + $classSections[$sid]['teacher_assistants'][] = $name; + } else { + $classSections[$sid]['main_teachers'][] = $name; + } + } + } catch (\Throwable $e) { + Log::warning('WhatsApp teacher list lookup failed: ' . $e->getMessage()); + } + + foreach ($classSections as &$section) { + $section['main_teachers'] = array_values(array_filter($section['main_teachers'] ?? [])); + $section['teacher_assistants'] = array_values(array_filter($section['teacher_assistants'] ?? [])); + $section['parents'] = array_values($section['parents'] ?? []); + } + unset($section); + + return array_values($classSections); + } +} diff --git a/app/Services/Whatsapp/WhatsappContextService.php b/app/Services/Whatsapp/WhatsappContextService.php new file mode 100644 index 00000000..de77a5cc --- /dev/null +++ b/app/Services/Whatsapp/WhatsappContextService.php @@ -0,0 +1,102 @@ + */ + private array $rosterPresenceCache = []; + + public function schoolYear(?string $override = null): string + { + $override = trim((string) $override); + if ($override !== '') { + return $override; + } + + return trim((string) (Configuration::getConfig('school_year') ?? '')); + } + + public function semester(?string $override = null): string + { + $override = trim((string) $override); + if ($override !== '') { + return $override; + } + + return trim((string) (Configuration::getConfig('semester') ?? '')); + } + + /** + * School-year aliases like 2025-2026, 2025/2026, 2025-26, 2025. + * + * @return array{0: array, 1: string, 2: int} + */ + public function schoolYearAliases(string $schoolYear): array + { + $trim = trim($schoolYear); + $normalized = str_replace(["\u{2013}", "\u{2014}", '/'], '-', $trim); + + if (preg_match('/\b(20\d{2})\b/', $normalized, $m)) { + $start = (int) $m[1]; + } else { + $start = (int) date('Y'); + } + + $end = $start + 1; + $canonical = $start . '-' . $end; + + $aliases = [ + $canonical, + $start . '/' . $end, + sprintf('%d-%02d', $start, $end % 100), + (string) $start, + ]; + + return [array_values(array_unique($aliases)), $canonical, $start]; + } + + /** + * Semester aliases for filtering. + * + * @return array + */ + public function semesterAliases(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 === '' ? [] : [$s]; + } + + public function hasRosterForYear(string $schoolYear): bool + { + $year = trim($schoolYear); + if ($year === '') { + return false; + } + + if (array_key_exists($year, $this->rosterPresenceCache)) { + return $this->rosterPresenceCache[$year]; + } + + $exists = StudentClass::query() + ->where('school_year', $year) + ->exists(); + + $this->rosterPresenceCache[$year] = $exists; + + return $exists; + } +} diff --git a/app/Services/Whatsapp/WhatsappInviteBundleService.php b/app/Services/Whatsapp/WhatsappInviteBundleService.php new file mode 100644 index 00000000..9e9e8de9 --- /dev/null +++ b/app/Services/Whatsapp/WhatsappInviteBundleService.php @@ -0,0 +1,347 @@ +whereKey($pickedId)->exists(); + $primaryId = $pickedId; + + if (!$isPrimary) { + $map = ParentModel::query() + ->select('firstparent_id', 'id') + ->where('id', $pickedId) + ->first(); + if (!$map || !$map->firstparent_id) { + return null; + } + $primaryId = (int) $map->firstparent_id; + } + + $primary = User::query() + ->select('id', 'firstname', 'lastname', 'email') + ->whereKey($primaryId) + ->first(); + if (!$primary) { + return null; + } + + $secondary = ParentModel::query() + ->select('id', 'secondparent_firstname', 'secondparent_lastname', 'secondparent_email') + ->where('firstparent_id', $primaryId) + ->where('id', '>', 0) + ->first(); + + $sectionRows = DB::table('student_class as sc') + ->select(DB::raw('DISTINCT sc.class_section_id')) + ->join('students as s', 's.id', '=', 'sc.student_id') + ->where('sc.school_year', $schoolYear) + ->where('s.parent_id', $primaryId) + ->get() + ->all(); + + $sections = []; + $linksSet = []; + foreach ($sectionRows as $row) { + $cid = (int) ($row->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; + } + + $emails = []; + if (!empty($primary->email)) { + $emails[$primary->email] = true; + } + if (!empty($secondary?->secondparent_email)) { + $emails[$secondary->secondparent_email] = true; + } + if (empty($emails)) { + return null; + } + + return [ + 'parent' => [ + 'id' => (int) $primary->id, + 'firstname' => (string) ($primary->firstname ?? ''), + 'lastname' => (string) ($primary->lastname ?? ''), + 'email' => (string) ($primary->email ?? ''), + 'source' => 'users', + ], + 'secondary_parents' => $secondary && !empty($secondary->id) ? [[ + 'id' => (int) $secondary->id, + 'firstname' => (string) ($secondary->secondparent_firstname ?? ''), + 'lastname' => (string) ($secondary->secondparent_lastname ?? ''), + 'email' => (string) ($secondary->secondparent_email ?? ''), + 'source' => 'parents', + ]] : [], + 'sections' => $sections, + 'links' => array_keys($linksSet), + 'emails' => array_keys($emails), + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + } + + public function bundlesForClass(int $classSectionId, array $linkBySection, string $schoolYear, string $semester): array + { + $link = (string) ($linkBySection[$classSectionId]['invite_link'] ?? ''); + if ($link === '') { + return []; + } + + $studentRows = DB::table('student_class') + ->select('student_id') + ->where('school_year', $schoolYear) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->where('class_section_id', $classSectionId) + ->get() + ->all(); + + if (empty($studentRows)) { + return []; + } + + $studentIds = array_values(array_unique(array_map(fn ($r) => (int) ($r->student_id ?? 0), $studentRows))); + if (empty($studentIds)) { + return []; + } + + $parentRows = DB::table('students') + ->select('id', 'parent_id') + ->whereIn('id', $studentIds) + ->get() + ->all(); + + $parentIds = []; + foreach ($parentRows as $row) { + $pid = (int) ($row->parent_id ?? 0); + if ($pid) { + $parentIds[$pid] = true; + } + } + $parentIds = array_keys($parentIds); + if (empty($parentIds)) { + return []; + } + + $primaryRows = DB::table('users') + ->select('id', 'firstname', 'lastname', 'email') + ->whereIn('id', $parentIds) + ->get() + ->all(); + + $profiles = []; + foreach ($primaryRows as $row) { + $uid = (int) ($row->id ?? 0); + $profiles[$uid] = [ + 'id' => $uid, + 'firstname' => (string) ($row->firstname ?? ''), + 'lastname' => (string) ($row->lastname ?? ''), + 'email' => (string) ($row->email ?? ''), + 'source' => 'users', + ]; + } + + $secondaryRows = DB::table('parents') + ->select('firstparent_id', 'id', 'secondparent_firstname', 'secondparent_lastname', 'secondparent_email') + ->where('id', '>', 0) + ->whereIn('firstparent_id', $parentIds) + ->get() + ->all(); + + $secondaryByPrimary = []; + foreach ($secondaryRows as $row) { + $fp = (int) ($row->firstparent_id ?? 0); + $secondaryByPrimary[$fp] = [ + 'id' => (int) ($row->id ?? 0), + 'firstname' => (string) ($row->secondparent_firstname ?? ''), + 'lastname' => (string) ($row->secondparent_lastname ?? ''), + 'email' => (string) ($row->secondparent_email ?? ''), + 'source' => 'parents', + ]; + } + + $bundles = []; + foreach ($parentIds as $pid) { + $profile = $profiles[$pid] ?? null; + if (!$profile) { + continue; + } + + $emails = []; + if (!empty($profile['email'])) { + $emails[$profile['email']] = true; + } + if (!empty($secondaryByPrimary[$pid]['email'])) { + $emails[$secondaryByPrimary[$pid]['email']] = true; + } + if (empty($emails)) { + continue; + } + + $bundles[] = [ + 'parent' => $profile, + 'secondary_parents' => !empty($secondaryByPrimary[$pid]['id']) ? [$secondaryByPrimary[$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), + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + } + + return $bundles; + } + + public function bundlesForAll(array $linkBySection, string $schoolYear, string $semester): array + { + $sectionRows = DB::table('student_class') + ->select(DB::raw('DISTINCT class_section_id')) + ->where('school_year', $schoolYear) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->orderBy('class_section_id', 'ASC') + ->get() + ->all(); + + $bundles = []; + foreach ($sectionRows as $row) { + $cid = (int) ($row->class_section_id ?? 0); + if (!$cid) { + continue; + } + + $classBundles = $this->bundlesForClass($cid, $linkBySection, $schoolYear, $semester); + foreach ($classBundles as $bundle) { + $bundles[] = $bundle; + } + } + + return $bundles; + } + + public function consolidateBundles(array $payloads): array + { + $normalizeEmails = 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); + }; + + $mergeUnique = static function (array $a, array $b): array { + $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); + }; + + $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 { + $byKey['name:' . (string) $row] = $row; + } + }; + + foreach ($a as $r) { + $put($byKey, $r); + } + foreach ($b as $r) { + $put($byKey, $r); + } + + return array_values($byKey); + }; + + $mergeLinks = static function (array $a, array $b): array { + $index = []; + $add = static function (&$index, $item) { + if (is_array($item)) { + $key = isset($item['class_section_id']) + ? '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 $bundle) { + $parent = $bundle['parent'] ?? []; + $pid = (int) ($parent['id'] ?? 0); + $primaryEmails = $normalizeEmails((array) ($bundle['emails'] ?? [])); + $groupKey = $pid > 0 ? ('pid:' . $pid) : ('mail:' . implode(',', $primaryEmails)); + + if (!isset($grouped[$groupKey])) { + $grouped[$groupKey] = [ + 'parent' => $parent, + 'emails' => $primaryEmails, + 'sections' => (array) ($bundle['sections'] ?? []), + 'links' => (array) ($bundle['links'] ?? []), + 'teachers' => (array) ($bundle['teachers'] ?? []), + 'items' => (array) ($bundle['items'] ?? []), + 'secondary_parents' => (array) ($bundle['secondary_parents'] ?? []), + ]; + continue; + } + + $grouped[$groupKey]['parent'] = !empty($parent) ? $parent : $grouped[$groupKey]['parent']; + $grouped[$groupKey]['emails'] = $normalizeEmails(array_merge($grouped[$groupKey]['emails'], $primaryEmails)); + $grouped[$groupKey]['sections'] = $mergeSections($grouped[$groupKey]['sections'], (array) ($bundle['sections'] ?? [])); + $grouped[$groupKey]['links'] = $mergeLinks($grouped[$groupKey]['links'], (array) ($bundle['links'] ?? [])); + $grouped[$groupKey]['teachers'] = $mergeUnique($grouped[$groupKey]['teachers'], (array) ($bundle['teachers'] ?? [])); + $grouped[$groupKey]['items'] = $mergeLinks($grouped[$groupKey]['items'], (array) ($bundle['items'] ?? [])); + $grouped[$groupKey]['secondary_parents'] = $mergeUnique($grouped[$groupKey]['secondary_parents'], (array) ($bundle['secondary_parents'] ?? [])); + } + + return array_values($grouped); + } +} diff --git a/app/Services/Whatsapp/WhatsappInviteNotificationService.php b/app/Services/Whatsapp/WhatsappInviteNotificationService.php new file mode 100644 index 00000000..6396bdc2 --- /dev/null +++ b/app/Services/Whatsapp/WhatsappInviteNotificationService.php @@ -0,0 +1,129 @@ + false, + 'message' => 'No event listener registered for WhatsappInvitesSend.', + 'triggered' => 0, + ]; + } + + $normalize = 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); + }; + + $triggered = 0; + foreach ($bundles as $bundle) { + try { + $primaryEmails = $normalize((array) ($bundle['emails'] ?? [])); + $secondaryEmails = $this->extractSecondaryEmails($bundle, $normalize); + + if (empty($secondaryEmails)) { + Log::info('No secondary emails for WhatsApp invite.', [ + 'parent_id' => $bundle['parent']['id'] ?? null, + ]); + } + + $to = $primaryEmails; + $cc = []; + + if (!empty($primaryEmails) && !empty($secondaryEmails)) { + $cc = array_values(array_diff($secondaryEmails, $primaryEmails)); + } elseif (empty($primaryEmails) && !empty($secondaryEmails)) { + $to = $secondaryEmails; + } + + if (empty($to)) { + Log::warning('Skipping WhatsApp invite: no recipient email(s).', [ + 'parent_id' => $bundle['parent']['id'] ?? null, + ]); + continue; + } + + event(new WhatsappInvitesSend([ + 'parent' => $bundle['parent'] ?? null, + 'sections' => $bundle['sections'] ?? [], + 'schoolYear' => $schoolYear, + 'semester' => $semester, + 'links' => $bundle['links'] ?? [], + 'teachers' => $bundle['teachers'] ?? [], + 'to_emails' => $to, + 'cc_emails' => $cc, + ])); + + $triggered++; + } catch (\Throwable $e) { + Log::error('WhatsappInvitesSend dispatch failed: ' . $e->getMessage()); + } + } + + return [ + 'ok' => true, + 'triggered' => $triggered, + ]; + } + + private function extractSecondaryEmails(array $bundle, callable $normalize): array + { + $collected = []; + + 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']; + } + } + } + + $parent = $bundle['parent'] ?? []; + foreach (['secondary_email', 'secondparent_email', 'sec_email', 'email_secondary'] as $key) { + if (!empty($parent[$key])) { + $collected[] = $parent[$key]; + } + } + if (!empty($parent['secondparent']['email'])) { + $collected[] = $parent['secondparent']['email']; + } + + try { + $primaryId = (int) ($parent['id'] ?? 0); + if ($primaryId > 0) { + $row = ParentModel::query() + ->select('secondparent_email') + ->where('firstparent_id', $primaryId) + ->orderByDesc('updated_at') + ->first(); + if (!empty($row?->secondparent_email)) { + $collected[] = $row->secondparent_email; + } + } + } catch (\Throwable $e) { + Log::warning('Secondary email DB lookup failed: ' . $e->getMessage()); + } + + return $normalize($collected); + } +} diff --git a/app/Services/Whatsapp/WhatsappInviteService.php b/app/Services/Whatsapp/WhatsappInviteService.php new file mode 100644 index 00000000..16357587 --- /dev/null +++ b/app/Services/Whatsapp/WhatsappInviteService.php @@ -0,0 +1,82 @@ +context->schoolYear(); + $semester = array_key_exists('semester', $payload) + ? trim((string) ($payload['semester'] ?? '')) + : $this->context->semester(); + + $links = WhatsappGroupLink::getAllForTerm($schoolYear, $semester); + $linkBySection = []; + foreach ($links as $link) { + $cid = (int) ($link->class_section_id ?? 0); + if ($cid && !isset($linkBySection[$cid])) { + $linkBySection[$cid] = $link->toArray(); + } + } + + $bundles = []; + if ($mode === 'parents') { + $parentIds = array_map('intval', (array) ($payload['parent_ids'] ?? [])); + foreach ($parentIds as $pickedId) { + if ($pickedId <= 0) { + continue; + } + $bundle = $this->bundleService->bundleForParent($pickedId, $linkBySection, $schoolYear, $semester); + if ($bundle && !empty($bundle['emails']) && !empty($bundle['links'])) { + $bundles[] = $bundle; + } + } + } elseif ($mode === 'class') { + $classSectionId = (int) ($payload['class_section_id'] ?? 0); + $classBundles = $this->bundleService->bundlesForClass($classSectionId, $linkBySection, $schoolYear, $semester); + foreach ($classBundles as $bundle) { + if (!empty($bundle['emails']) && !empty($bundle['links'])) { + $bundles[] = $bundle; + } + } + } elseif ($mode === 'all') { + $allBundles = $this->bundleService->bundlesForAll($linkBySection, $schoolYear, $semester); + foreach ($allBundles as $bundle) { + if (!empty($bundle['emails']) && !empty($bundle['links'])) { + $bundles[] = $bundle; + } + } + } + + if (empty($bundles)) { + return [ + 'ok' => false, + 'message' => 'No recipients found (missing links or emails).', + 'triggered' => 0, + ]; + } + + $consolidated = $this->bundleService->consolidateBundles($bundles); + $result = $this->notificationService->dispatch($consolidated, $schoolYear, $semester); + + if (!($result['ok'] ?? false)) { + return $result + ['ok' => false]; + } + + return [ + 'ok' => true, + 'triggered' => (int) ($result['triggered'] ?? 0), + ]; + } +} diff --git a/app/Services/Whatsapp/WhatsappLinkService.php b/app/Services/Whatsapp/WhatsappLinkService.php new file mode 100644 index 00000000..03e5f727 --- /dev/null +++ b/app/Services/Whatsapp/WhatsappLinkService.php @@ -0,0 +1,125 @@ +forYear($schoolYear); + } + + if (array_key_exists('semester', $filters)) { + $semester = trim((string) ($filters['semester'] ?? '')); + if ($semester !== '') { + $query->forSemester($semester); + } + } + + if (!empty($filters['class_section_id'])) { + $query->forSection((int) $filters['class_section_id']); + } + + if (array_key_exists('active', $filters) && $filters['active'] !== null && $filters['active'] !== '') { + $query->where('active', (int) (bool) $filters['active']); + } + + if (!empty($filters['search'])) { + $search = trim((string) $filters['search']); + $query->where(function ($q) use ($search) { + $q->where('class_section_name', 'like', '%' . $search . '%') + ->orWhere('invite_link', 'like', '%' . $search . '%') + ->orWhere('class_section_id', $search); + }); + } + + $sortBy = (string) ($filters['sort_by'] ?? 'updated_at'); + $sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc'; + + $allowedSorts = ['class_section_id', 'class_section_name', 'school_year', 'semester', 'active', 'updated_at']; + if (!in_array($sortBy, $allowedSorts, true)) { + $sortBy = 'updated_at'; + } + + $perPage = (int) ($filters['per_page'] ?? 20); + $perPage = $perPage > 0 ? $perPage : 20; + + return $query->orderBy($sortBy, $sortDir)->paginate($perPage); + } + + public function findOrFail(int $id): WhatsappGroupLink + { + $link = WhatsappGroupLink::query()->find($id); + if (!$link) { + throw new ModelNotFoundException('WhatsApp group link not found.'); + } + + return $link; + } + + public function upsert(array $payload): WhatsappGroupLink + { + $schoolYear = trim((string) ($payload['school_year'] ?? '')) ?: $this->context->schoolYear(); + $semester = trim((string) ($payload['semester'] ?? '')); + $sectionId = (int) ($payload['class_section_id'] ?? 0); + + $sectionName = trim((string) ($payload['class_section_name'] ?? '')); + if ($sectionName === '' && $sectionId > 0) { + $sectionName = $this->resolveSectionName($sectionId); + } + + $link = WhatsappGroupLink::upsertLinkForSection( + $sectionId, + $sectionName !== '' ? $sectionName : ('Section ' . $sectionId), + $schoolYear, + $semester, + (string) ($payload['invite_link'] ?? ''), + (bool) ($payload['active'] ?? true) + ); + + return $link; + } + + public function update(int $id, array $payload): WhatsappGroupLink + { + $link = $this->findOrFail($id); + + if (array_key_exists('class_section_name', $payload)) { + $name = trim((string) ($payload['class_section_name'] ?? '')); + if ($name === '' && !empty($payload['class_section_id'])) { + $name = $this->resolveSectionName((int) $payload['class_section_id']); + } + $payload['class_section_name'] = $name; + } + + $link->fill($payload); + $link->save(); + + return $link; + } + + public function delete(int $id): void + { + $link = $this->findOrFail($id); + $link->delete(); + } + + private function resolveSectionName(int $sectionId): string + { + $name = ClassSection::getClassSectionNameBySectionId($sectionId); + return $name !== null && $name !== '' ? $name : ('Section ' . $sectionId); + } +} diff --git a/app/Services/Whatsapp/WhatsappMembershipService.php b/app/Services/Whatsapp/WhatsappMembershipService.php new file mode 100644 index 00000000..cf6189e4 --- /dev/null +++ b/app/Services/Whatsapp/WhatsappMembershipService.php @@ -0,0 +1,106 @@ + false, + 'message' => 'Membership table missing. Please run migrations.', + ]; + } + + $classSectionId = (int) ($payload['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + return [ + 'ok' => false, + 'message' => 'Invalid class section.', + ]; + } + + $schoolYear = trim((string) ($payload['school_year'] ?? '')); + $semester = trim((string) ($payload['semester'] ?? '')); + $primaryId = (int) ($payload['primary_id'] ?? 0); + $secondId = (int) ($payload['second_id'] ?? 0); + $primaryMember = $payload['primary_member'] ?? null; + $secondMember = $payload['second_member'] ?? null; + + $saved = DB::transaction(function () use ( + $classSectionId, + $schoolYear, + $semester, + $primaryId, + $secondId, + $primaryMember, + $secondMember, + $verifiedBy + ) { + $saved = []; + + if ($primaryId > 0) { + $id = WhatsappGroupMembership::upsertMembership( + $classSectionId, + $schoolYear, + $semester, + WhatsappGroupMembership::TYPE_PRIMARY, + $primaryId, + (bool) $primaryMember, + $verifiedBy + ); + $saved[] = 'primary=' . $primaryId . ':' . ($primaryMember ? '1' : '0'); + if ($id <= 0) { + Log::warning('Failed to upsert primary membership.', [ + 'class_section_id' => $classSectionId, + 'primary_id' => $primaryId, + ]); + } + } + + if ($secondId > 0) { + $id = WhatsappGroupMembership::upsertMembership( + $classSectionId, + $schoolYear, + $semester, + WhatsappGroupMembership::TYPE_SECOND, + $secondId, + (bool) $secondMember, + $verifiedBy + ); + $saved[] = 'second=' . $secondId . ':' . ($secondMember ? '1' : '0'); + if ($id <= 0) { + Log::warning('Failed to upsert second membership.', [ + 'class_section_id' => $classSectionId, + 'second_id' => $secondId, + ]); + } + } + + return $saved; + }); + + if (empty($saved)) { + return [ + 'ok' => false, + 'message' => 'Nothing to save (missing parent IDs).', + ]; + } + + return [ + 'ok' => true, + 'saved' => $saved, + 'term' => [ + 'school_year' => $schoolYear, + 'semester' => $semester, + ], + ]; + } +} diff --git a/app/old/WhatsappController.php b/app/old/WhatsappController.php deleted file mode 100644 index 8bfdd0e1..00000000 --- a/app/old/WhatsappController.php +++ /dev/null @@ -1,1329 +0,0 @@ - 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; - } -} diff --git a/routes/api.php b/routes/api.php index 34da0a93..8fe03b40 100755 --- a/routes/api.php +++ b/routes/api.php @@ -182,6 +182,18 @@ Route::prefix('v1')->group(function () { Route::middleware('auth:sanctum')->group(function () { Route::get('stats', [StatsController::class, 'index']); + Route::prefix('whatsapp')->group(function () { + Route::get('links', [WhatsappController::class, 'index']); + Route::post('links', [WhatsappController::class, 'store']); + Route::get('links/{linkId}', [WhatsappController::class, 'show']); + Route::patch('links/{linkId}', [WhatsappController::class, 'update']); + Route::delete('links/{linkId}', [WhatsappController::class, 'destroy']); + Route::get('parent-contacts', [WhatsappController::class, 'parentContacts']); + Route::get('parent-contacts-by-class', [WhatsappController::class, 'parentContactsByClass']); + Route::post('invites/send', [WhatsappController::class, 'sendInvites']); + Route::post('membership', [WhatsappController::class, 'updateMembership']); + }); + Route::prefix('teachers')->group(function () { Route::get('classes', [TeacherController::class, 'classes']); Route::post('classes/switch', [TeacherController::class, 'switchClass']); diff --git a/test-output.txt b/test-output.txt new file mode 100644 index 00000000..4a6b594b --- /dev/null +++ b/test-output.txt @@ -0,0 +1,292 @@ +Xdebug: [Step Debug] Could not connect to debugging client. Tried: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port). + +Xdebug: [Step Debug] Could not connect to debugging client. Tried: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port). + + PASS Tests\Unit\Models\WhatsappGroupLinkTest + ✓ model metadata is converted 0.01s + ✓ model class loads + + PASS Tests\Unit\Models\WhatsappGroupMembershipTest + ✓ model metadata is converted 0.01s + ✓ model class loads + + PASS Tests\Unit\Models\WhatsappInviteLogTest + ✓ model metadata is converted + ✓ model class loads + + + FAIL Tests\Unit\Services\Whatsapp\WhatsappContactServiceTest + ⨯ list parent contacts returns primary and second 0.40s + + + + PASS Tests\Unit\Services\Whatsapp\WhatsappContextServiceTest + ✓ school year aliases generate expected values 0.28s + ✓ has roster for year 0.28s + + + + FAIL Tests\Unit\Services\Whatsapp\WhatsappInviteBundleServiceTest + ⨯ bundle for parent returns links and emails 0.28s + ✓ consolidate bundles merges by parent 0.28s + + + + PASS Tests\Unit\Services\Whatsapp\WhatsappInviteNotificationServiceTest + ✓ dispatch returns error when no listeners 0.29s + ✓ dispatch triggers event when listener registered 0.28s + + + FAIL Tests\Unit\Services\Whatsapp\WhatsappInviteServiceTest + ⨯ send returns success for parent mode 0.28s + + + + PASS Tests\Unit\Services\Whatsapp\WhatsappLinkServiceTest + ✓ upsert creates link 0.28s + ✓ paginate filters by active 0.28s + + + + PASS Tests\Unit\Services\Whatsapp\WhatsappMembershipServiceTest + ✓ update membership saves primary 0.29s + ✓ update membership returns error when table missing 0.29s + + + + + + + + FAIL Tests\Feature\Api\V1\Whatsapp\WhatsappInviteControllerTest + ⨯ parent contacts endpoint returns contacts 0.31s + ⨯ parent contacts by class endpoint returns sections 0.29s + ⨯ send invites returns success when listener registered 0.29s + ⨯ send invites returns error when no listener 0.29s + ⨯ membership update saves record 0.29s + ✓ send invites validation rejects payload 0.31s + + + + + + + + + FAIL Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest + ✓ index returns paginated links 0.30s + ✓ show returns single link 0.30s + ⨯ store creates link 0.29s + ⨯ update updates link 0.29s + ✓ destroy deletes link 0.31s + ✓ store validation rejects invalid payload 0.30s + ✓ requires authentication 0.29s + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Unit\Services\Whatsapp\WhatsappContactServi… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:52, 2026-03-10 20:29:52)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php:38 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Unit\Services\Whatsapp\WhatsappInviteBundle… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:53, 2026-03-10 20:29:53)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php:37 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Unit\Services\Whatsapp\WhatsappInviteServic… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:54, 2026-03-10 20:29:54)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php:65 + 10 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php:21 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:22 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 + 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:46 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 + 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:60 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 + 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:81 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException + SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:57, 2026-03-10 20:29:57)) + + at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838 + 834▕ $exceptionType = $this->isUniqueConstraintError($e) + 835▕ ? UniqueConstraintViolationException::class + 836▕ : QueryException::class; + 837▕ + ➜ 838▕ throw new $exceptionType( + 839▕ $this->getNameWithReadWriteType(), + 840▕ $query, + 841▕ $this->prepareBindings($bindings), + 842▕ $e, + + +8 vendor frames  + 9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134 + 10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:99 + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest > store… + Expected response status code [201] but received 422. +Failed asserting that 422 is identical to 201. + +The following errors occurred during the last request: + +{ + "message": "Validation failed.", + "errors": { + "class_section_id": [ + "The class section id field is required." + ], + "invite_link": [ + "The invite link field is required." + ] + } +} + + at tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php:78 + 74▕ ]; + 75▕ + 76▕ $response = $this->postJson('/api/v1/whatsapp/links', $payload); + 77▕ + ➜ 78▕ $response->assertStatus(201); + 79▕ $response->assertJsonPath('status', true); + 80▕ $this->assertDatabaseHas('whatsapp_group_links', [ + 81▕ 'class_section_id' => 202, + 82▕ 'invite_link' => 'https://chat.whatsapp.com/new', + + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest > update… + Failed asserting that a row in the table [whatsapp_group_links] matches the attributes { + "id": 1, + "invite_link": "https:\/\/chat.whatsapp.com\/updated", + "active": 0 +}. + +Found similar results: [ + { + "id": 1, + "invite_link": "https:\/\/chat.whatsapp.com\/old", + "active": 1 + } +]. + + at tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php:109 + 105▕ ]); + 106▕ + 107▕ $response->assertOk(); + 108▕ $response->assertJsonPath('status', true); + ➜ 109▕ $this->assertDatabaseHas('whatsapp_group_links', [ + 110▕ 'id' => $linkId, + 111▕ 'invite_link' => 'https://chat.whatsapp.com/updated', + 112▕ 'active' => 0, + 113▕ ]); + + + Tests: 10 failed, 21 passed (52 assertions) + Duration: 7.96s diff --git a/tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php b/tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php new file mode 100644 index 00000000..578b2f87 --- /dev/null +++ b/tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php @@ -0,0 +1,214 @@ +createUser(); + Sanctum::actingAs($user); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->getJson('/api/v1/whatsapp/parent-contacts?school_year=2025-2026&semester=Fall'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.contacts')); + } + + public function test_parent_contacts_by_class_endpoint_returns_sections(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedInviteData(); + + $response = $this->getJson('/api/v1/whatsapp/parent-contacts-by-class?school_year=2025-2026'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.class_sections')); + } + + public function test_send_invites_returns_success_when_listener_registered(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedInviteData(); + Event::listen(WhatsappInvitesSend::class, static function () { + }); + + $response = $this->postJson('/api/v1/whatsapp/invites/send', [ + 'mode' => 'parents', + 'parent_ids' => [10], + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertGreaterThanOrEqual(1, (int) $response->json('data.triggered')); + } + + public function test_send_invites_returns_error_when_no_listener(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedInviteData(); + + $response = $this->postJson('/api/v1/whatsapp/invites/send', [ + 'mode' => 'parents', + 'parent_ids' => [10], + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('status', false); + } + + public function test_membership_update_saves_record(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedInviteData(); + + $response = $this->postJson('/api/v1/whatsapp/membership', [ + 'class_section_id' => 200, + 'primary_id' => 10, + 'primary_member' => true, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertDatabaseHas('whatsapp_group_memberships', [ + 'class_section_id' => 200, + 'subject_type' => 'primary', + 'subject_id' => 10, + 'is_member' => 1, + ]); + } + + public function test_send_invites_validation_rejects_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/whatsapp/invites/send', [ + 'mode' => 'parents', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + } + + private function seedInviteData(): void + { + DB::table('classSection')->insert([ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'class_id' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'school_id' => 1, + 'firstname' => 'Student', + 'lastname' => 'One', + 'parent_id' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 200, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('whatsapp_group_links')->insert([ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/abc', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createUser(): User + { + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'One', + 'cellphone' => '5555555555', + 'email' => 'parent1@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return User::query()->findOrFail(10); + } +} diff --git a/tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php b/tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php new file mode 100644 index 00000000..0392ad5d --- /dev/null +++ b/tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php @@ -0,0 +1,184 @@ +createUser(); + Sanctum::actingAs($user); + + DB::table('whatsapp_group_links')->insert([ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/abc', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->getJson('/api/v1/whatsapp/links'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.links')); + $this->assertArrayHasKey('class_section_id', $response->json('data.links.0')); + } + + public function test_show_returns_single_link(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $linkId = DB::table('whatsapp_group_links')->insertGetId([ + 'class_section_id' => 201, + 'class_section_name' => 'Grade 2', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/xyz', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->getJson('/api/v1/whatsapp/links/' . $linkId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $response->assertJsonPath('data.link.id', $linkId); + } + + public function test_store_creates_link(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $payload = [ + 'class_section_id' => 202, + 'class_section_name' => 'Grade 3', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/new', + 'active' => true, + ]; + + $response = $this->postJson('/api/v1/whatsapp/links', $payload); + + $response->assertStatus(201); + $response->assertJsonPath('status', true); + $this->assertDatabaseHas('whatsapp_group_links', [ + 'class_section_id' => 202, + 'invite_link' => 'https://chat.whatsapp.com/new', + ]); + } + + public function test_update_updates_link(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $linkId = DB::table('whatsapp_group_links')->insertGetId([ + 'class_section_id' => 203, + 'class_section_name' => 'Grade 4', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/old', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->patchJson('/api/v1/whatsapp/links/' . $linkId, [ + 'invite_link' => 'https://chat.whatsapp.com/updated', + 'active' => false, + ]); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertDatabaseHas('whatsapp_group_links', [ + 'id' => $linkId, + 'invite_link' => 'https://chat.whatsapp.com/updated', + 'active' => 0, + ]); + } + + public function test_destroy_deletes_link(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $linkId = DB::table('whatsapp_group_links')->insertGetId([ + 'class_section_id' => 204, + 'class_section_name' => 'Grade 5', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/delete', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->deleteJson('/api/v1/whatsapp/links/' . $linkId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertDatabaseMissing('whatsapp_group_links', ['id' => $linkId]); + } + + public function test_store_validation_rejects_invalid_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/whatsapp/links', [ + 'class_section_id' => null, + ]); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/whatsapp/links'); + + $response->assertStatus(401); + } + + private function createUser(): User + { + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return User::query()->findOrFail(1); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php new file mode 100644 index 00000000..77152bf1 --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php @@ -0,0 +1,57 @@ +insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'One', + 'cellphone' => '5555555555', + 'email' => 'parent1@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new WhatsappContactService(new WhatsappContextService()); + $contacts = $service->listParentContacts('2025-2026', 'Fall'); + + $this->assertCount(2, $contacts); + $this->assertSame('Primary Parent', $contacts[0]['type']); + $this->assertSame('Second Parent', $contacts[1]['type']); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappContextServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappContextServiceTest.php new file mode 100644 index 00000000..f4b7c0d5 --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappContextServiceTest.php @@ -0,0 +1,41 @@ +schoolYearAliases('2025/2026'); + + $this->assertContains('2025-2026', $aliases); + $this->assertSame('2025-2026', $canonical); + $this->assertSame(2025, $startYear); + } + + public function test_has_roster_for_year(): void + { + DB::table('student_class')->insert([ + 'student_id' => 1, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new WhatsappContextService(); + + $this->assertTrue($service->hasRosterForYear('2025-2026')); + $this->assertFalse($service->hasRosterForYear('2030-2031')); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php new file mode 100644 index 00000000..903f151c --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php @@ -0,0 +1,110 @@ +insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'One', + 'cellphone' => '5555555555', + 'email' => 'parent1@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'school_id' => 1, + 'firstname' => 'Student', + 'lastname' => 'One', + 'parent_id' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 200, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $linkBySection = [ + 200 => [ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'invite_link' => 'https://chat.whatsapp.com/abc', + ], + ]; + + $service = new WhatsappInviteBundleService(); + $bundle = $service->bundleForParent(10, $linkBySection, '2025-2026', 'Fall'); + + $this->assertNotNull($bundle); + $this->assertNotEmpty($bundle['emails']); + $this->assertNotEmpty($bundle['links']); + } + + public function test_consolidate_bundles_merges_by_parent(): void + { + $service = new WhatsappInviteBundleService(); + + $merged = $service->consolidateBundles([ + [ + 'parent' => ['id' => 10], + 'emails' => ['a@example.com'], + 'sections' => [['class_section_id' => 1]], + 'links' => ['https://chat.whatsapp.com/a'], + ], + [ + 'parent' => ['id' => 10], + 'emails' => ['b@example.com'], + 'sections' => [['class_section_id' => 2]], + 'links' => ['https://chat.whatsapp.com/b'], + ], + ]); + + $this->assertCount(1, $merged); + $this->assertCount(2, $merged[0]['emails']); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappInviteNotificationServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappInviteNotificationServiceTest.php new file mode 100644 index 00000000..83eb0deb --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappInviteNotificationServiceTest.php @@ -0,0 +1,39 @@ +dispatch([], '2025-2026', 'Fall'); + + $this->assertFalse($result['ok']); + } + + public function test_dispatch_triggers_event_when_listener_registered(): void + { + Event::listen(WhatsappInvitesSend::class, static function () { + }); + + $service = new WhatsappInviteNotificationService(); + $result = $service->dispatch([[ + 'parent' => ['id' => 10], + 'emails' => ['parent1@example.com'], + 'sections' => [], + 'links' => ['https://chat.whatsapp.com/abc'], + ]], '2025-2026', 'Fall'); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['triggered']); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php new file mode 100644 index 00000000..375a7a8e --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php @@ -0,0 +1,111 @@ +seedInviteData(); + Event::listen(WhatsappInvitesSend::class, static function () { + }); + + $service = new WhatsappInviteService( + new WhatsappContextService(), + new WhatsappInviteBundleService(), + new WhatsappInviteNotificationService() + ); + + $result = $service->send([ + 'mode' => 'parents', + 'parent_ids' => [10], + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $this->assertTrue($result['ok']); + $this->assertGreaterThanOrEqual(1, $result['triggered']); + } + + private function seedInviteData(): void + { + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'One', + 'cellphone' => '5555555555', + 'email' => 'parent1@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'school_id' => 1, + 'firstname' => 'Student', + 'lastname' => 'One', + 'parent_id' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 200, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('whatsapp_group_links')->insert([ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/abc', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappLinkServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappLinkServiceTest.php new file mode 100644 index 00000000..1ae3e506 --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappLinkServiceTest.php @@ -0,0 +1,66 @@ +upsert([ + 'class_section_id' => 300, + 'class_section_name' => 'Grade 6', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/grade6', + 'active' => true, + ]); + + $this->assertNotEmpty($link->id); + $this->assertDatabaseHas('whatsapp_group_links', [ + 'class_section_id' => 300, + 'invite_link' => 'https://chat.whatsapp.com/grade6', + ]); + } + + public function test_paginate_filters_by_active(): void + { + DB::table('whatsapp_group_links')->insert([ + [ + 'class_section_id' => 301, + 'class_section_name' => 'Grade 7', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/a', + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'class_section_id' => 302, + 'class_section_name' => 'Grade 8', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'invite_link' => 'https://chat.whatsapp.com/b', + 'active' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new WhatsappLinkService(new WhatsappContextService()); + $page = $service->paginate(['active' => true]); + + $this->assertCount(1, $page->items()); + $this->assertSame(301, $page->items()[0]->class_section_id); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappMembershipServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappMembershipServiceTest.php new file mode 100644 index 00000000..6b122d10 --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappMembershipServiceTest.php @@ -0,0 +1,49 @@ +updateMembership([ + 'class_section_id' => 200, + 'primary_id' => 10, + 'primary_member' => true, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ], 1); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('whatsapp_group_memberships', [ + 'class_section_id' => 200, + 'subject_type' => 'primary', + 'subject_id' => 10, + 'is_member' => 1, + ]); + } + + public function test_update_membership_returns_error_when_table_missing(): void + { + Schema::shouldReceive('hasTable')->with('whatsapp_group_memberships')->andReturn(false); + + $service = new WhatsappMembershipService(); + $result = $service->updateMembership([ + 'class_section_id' => 200, + 'primary_id' => 10, + 'primary_member' => true, + ]); + + $this->assertFalse($result['ok']); + $this->assertSame('Membership table missing. Please run migrations.', $result['message']); + } +}