add whatsup logic
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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,
|
||||
|
||||
[2m+8 vendor frames [22m
|
||||
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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user