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