add whatsup logic

This commit is contained in:
root
2026-03-10 16:31:35 -04:00
parent 20ee70d153
commit 3bf4c687da
33 changed files with 2800 additions and 1329 deletions
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WhatsappInvitesSend
{
use Dispatchable;
use SerializesModels;
public function __construct(public array $payload)
{
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Whatsapp\WhatsappInviteSendRequest;
use App\Http\Requests\Whatsapp\WhatsappLinkIndexRequest;
use App\Http\Requests\Whatsapp\WhatsappLinkStoreRequest;
use App\Http\Requests\Whatsapp\WhatsappLinkUpdateRequest;
use App\Http\Requests\Whatsapp\WhatsappMembershipUpdateRequest;
use App\Http\Requests\Whatsapp\WhatsappParentContactsByClassRequest;
use App\Http\Requests\Whatsapp\WhatsappParentContactsRequest;
use App\Http\Resources\Whatsapp\WhatsappClassSectionContactResource;
use App\Http\Resources\Whatsapp\WhatsappGroupLinkCollection;
use App\Http\Resources\Whatsapp\WhatsappGroupLinkResource;
use App\Http\Resources\Whatsapp\WhatsappParentContactResource;
use App\Services\Whatsapp\WhatsappContactService;
use App\Services\Whatsapp\WhatsappContextService;
use App\Services\Whatsapp\WhatsappInviteService;
use App\Services\Whatsapp\WhatsappLinkService;
use App\Services\Whatsapp\WhatsappMembershipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class WhatsappController extends BaseApiController
{
public function __construct(
private WhatsappLinkService $linkService,
private WhatsappContactService $contactService,
private WhatsappInviteService $inviteService,
private WhatsappMembershipService $membershipService,
private WhatsappContextService $context
) {
parent::__construct();
}
public function index(WhatsappLinkIndexRequest $request): JsonResponse
{
$links = $this->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.');
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule;
class WhatsappInviteSendRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule;
class WhatsappLinkIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'])],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
class WhatsappLinkStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
class WhatsappLinkUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
class WhatsappMembershipUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
class WhatsappParentContactsByClassRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Whatsapp;
use App\Http\Requests\ApiFormRequest;
class WhatsappParentContactsRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources\Whatsapp;
use Illuminate\Http\Resources\Json\JsonResource;
class WhatsappClassSectionContactResource extends JsonResource
{
public function toArray($request): array
{
return [
'class_section_id' => (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'] ?? []),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Whatsapp;
use Illuminate\Http\Resources\Json\JsonResource;
class WhatsappClassSectionParentResource extends JsonResource
{
public function toArray($request): array
{
return [
'class_section_id' => (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,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Whatsapp;
use Illuminate\Http\Resources\Json\ResourceCollection;
class WhatsappGroupLinkCollection extends ResourceCollection
{
public $collects = WhatsappGroupLinkResource::class;
public function toArray($request): array
{
return parent::toArray($request);
}
public function with($request): array
{
return [
'meta' => [
'total' => $this->resource->total() ?? null,
'per_page' => $this->resource->perPage() ?? null,
'current_page' => $this->resource->currentPage() ?? null,
'last_page' => $this->resource->lastPage() ?? null,
],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources\Whatsapp;
use Illuminate\Http\Resources\Json\JsonResource;
class WhatsappGroupLinkResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (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,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Whatsapp;
use Illuminate\Http\Resources\Json\JsonResource;
class WhatsappParentContactResource extends JsonResource
{
public function toArray($request): array
{
return [
'firstname' => (string) ($this['firstname'] ?? ''),
'lastname' => (string) ($this['lastname'] ?? ''),
'email' => (string) ($this['email'] ?? ''),
'phone' => (string) ($this['phone'] ?? ''),
'type' => (string) ($this['type'] ?? ''),
];
}
}
@@ -0,0 +1,266 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\WhatsappGroupMembership;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class WhatsappContactService
{
public function __construct(private WhatsappContextService $context)
{
}
public function listParentContacts(?string $schoolYear, ?string $semester): array
{
$schoolYear = trim((string) $schoolYear);
$semester = trim((string) $semester);
$builder = DB::table('parents as sp');
$builder->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);
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\Configuration;
use App\Models\StudentClass;
class WhatsappContextService
{
/** @var array<string,bool> */
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<int,string>, 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<int,string>
*/
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;
}
}
@@ -0,0 +1,347 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\ParentModel;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class WhatsappInviteBundleService
{
public function bundleForParent(int $pickedId, array $linkBySection, string $schoolYear, string $semester): ?array
{
$isPrimary = User::query()->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);
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Services\Whatsapp;
use App\Events\WhatsappInvitesSend;
use App\Models\ParentModel;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
class WhatsappInviteNotificationService
{
public function dispatch(array $bundles, string $schoolYear, string $semester): array
{
$listenerCount = Event::hasListeners(WhatsappInvitesSend::class) ? 1 : 0;
if ($listenerCount === 0) {
Log::warning('WhatsappInvitesSend has no listeners registered.');
return [
'ok' => 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);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\WhatsappGroupLink;
class WhatsappInviteService
{
public function __construct(
private WhatsappContextService $context,
private WhatsappInviteBundleService $bundleService,
private WhatsappInviteNotificationService $notificationService
) {
}
public function send(array $payload): array
{
$mode = trim((string) ($payload['mode'] ?? ''));
$schoolYear = trim((string) ($payload['school_year'] ?? '')) ?: $this->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),
];
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\ClassSection;
use App\Models\WhatsappGroupLink;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class WhatsappLinkService
{
public function __construct(private WhatsappContextService $context)
{
}
public function paginate(array $filters): LengthAwarePaginator
{
$query = WhatsappGroupLink::query();
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
if ($schoolYear !== '') {
$query->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);
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\WhatsappGroupMembership;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class WhatsappMembershipService
{
public function updateMembership(array $payload, ?int $verifiedBy = null): array
{
if (!Schema::hasTable('whatsapp_group_memberships')) {
Log::error('WhatsApp membership table missing.');
return [
'ok' => 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,
],
];
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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']);
+292
View File
@@ -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
@@ -0,0 +1,214 @@
<?php
namespace Tests\Feature\Api\V1\Whatsapp;
use App\Events\WhatsappInvitesSend;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class WhatsappInviteControllerTest extends TestCase
{
use RefreshDatabase;
public function test_parent_contacts_endpoint_returns_contacts(): void
{
$user = $this->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);
}
}
@@ -0,0 +1,184 @@
<?php
namespace Tests\Feature\Api\V1\Whatsapp;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class WhatsappLinkControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_paginated_links(): void
{
$user = $this->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);
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Services\Whatsapp\WhatsappContactService;
use App\Services\Whatsapp\WhatsappContextService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class WhatsappContactServiceTest extends TestCase
{
use RefreshDatabase;
public function test_list_parent_contacts_returns_primary_and_second(): 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(),
]);
$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']);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Services\Whatsapp\WhatsappContextService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class WhatsappContextServiceTest extends TestCase
{
use RefreshDatabase;
public function test_school_year_aliases_generate_expected_values(): void
{
$service = new WhatsappContextService();
[$aliases, $canonical, $startYear] = $service->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'));
}
}
@@ -0,0 +1,110 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Services\Whatsapp\WhatsappInviteBundleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class WhatsappInviteBundleServiceTest extends TestCase
{
use RefreshDatabase;
public function test_bundle_for_parent_returns_links_and_emails(): 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(),
]);
$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']);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Events\WhatsappInvitesSend;
use App\Services\Whatsapp\WhatsappInviteNotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class WhatsappInviteNotificationServiceTest extends TestCase
{
use RefreshDatabase;
public function test_dispatch_returns_error_when_no_listeners(): void
{
$service = new WhatsappInviteNotificationService();
$result = $service->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']);
}
}
@@ -0,0 +1,111 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Events\WhatsappInvitesSend;
use App\Services\Whatsapp\WhatsappContextService;
use App\Services\Whatsapp\WhatsappInviteBundleService;
use App\Services\Whatsapp\WhatsappInviteNotificationService;
use App\Services\Whatsapp\WhatsappInviteService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class WhatsappInviteServiceTest extends TestCase
{
use RefreshDatabase;
public function test_send_returns_success_for_parent_mode(): void
{
$this->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(),
]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Services\Whatsapp\WhatsappContextService;
use App\Services\Whatsapp\WhatsappLinkService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class WhatsappLinkServiceTest extends TestCase
{
use RefreshDatabase;
public function test_upsert_creates_link(): void
{
$service = new WhatsappLinkService(new WhatsappContextService());
$link = $service->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);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Unit\Services\Whatsapp;
use App\Services\Whatsapp\WhatsappMembershipService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class WhatsappMembershipServiceTest extends TestCase
{
use RefreshDatabase;
public function test_update_membership_saves_primary(): void
{
$service = new WhatsappMembershipService();
$result = $service->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']);
}
}