diff --git a/app/Console/Commands/AttendanceAutoPublishCommand.php b/app/Console/Commands/AttendanceAutoPublishCommand.php new file mode 100644 index 00000000..fabeff3d --- /dev/null +++ b/app/Console/Commands/AttendanceAutoPublishCommand.php @@ -0,0 +1,26 @@ +run(); + + $this->info(sprintf( + 'Auto-publish checked at %s (TZ: %s). Published rows: %d.', + $result['checked_at'], + $result['timezone'], + $result['published'] + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CheckMissedPaymentsCommand.php b/app/Console/Commands/CheckMissedPaymentsCommand.php new file mode 100644 index 00000000..fff09126 --- /dev/null +++ b/app/Console/Commands/CheckMissedPaymentsCommand.php @@ -0,0 +1,26 @@ +findUsersWithMissedPayments(); + if (empty($users)) { + $this->info('No missed payments found.'); + return self::SUCCESS; + } + + $result = $service->sendReminders($users); + $this->info(sprintf('Finished checking missed payments. Sent: %d, Failed: %d.', $result['sent'], $result['failed'])); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CleanupExpiredNotificationsCommand.php b/app/Console/Commands/CleanupExpiredNotificationsCommand.php new file mode 100644 index 00000000..94bcabee --- /dev/null +++ b/app/Console/Commands/CleanupExpiredNotificationsCommand.php @@ -0,0 +1,26 @@ +cleanupExpired(); + + if ($count <= 0) { + $this->info('No expired notifications found to soft delete.'); + return self::SUCCESS; + } + + $this->info("Soft-deleted {$count} expired notifications."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CleanupPasswordResetsCommand.php b/app/Console/Commands/CleanupPasswordResetsCommand.php new file mode 100644 index 00000000..16d1bdbd --- /dev/null +++ b/app/Console/Commands/CleanupPasswordResetsCommand.php @@ -0,0 +1,22 @@ +option('days'); + $count = $service->cleanup($days > 0 ? $days : 30); + + $this->info("Deleted {$count} old password reset request(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ConfigUpdateCommand.php b/app/Console/Commands/ConfigUpdateCommand.php new file mode 100644 index 00000000..ac90ecbc --- /dev/null +++ b/app/Console/Commands/ConfigUpdateCommand.php @@ -0,0 +1,37 @@ +option('task') ?: $this->argument('task') ?: ''); + $dry = (bool) $this->option('dry'); + $force = (bool) $this->option('force'); + $tzName = (string) ($this->option('tz') ?: (config('School')->attendance['timezone'] ?? 'UTC')); + $tz = new DateTimeZone($tzName); + + if ($task === '' || !in_array($task, $service->availableTasks(), true)) { + $this->error('Invalid or missing --task. Available: ' . implode(', ', $service->availableTasks())); + return self::FAILURE; + } + + $result = $service->runTask($task, $dry, $force, $tz); + if (!$result['ok']) { + $this->error($result['message'] ?? "Task '{$task}' failed."); + return self::FAILURE; + } + + $this->info("Task '{$task}' finished successfully." . ($dry ? ' [DRY RUN]' : '')); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/DeleteInactiveUsersCommand.php b/app/Console/Commands/DeleteInactiveUsersCommand.php new file mode 100644 index 00000000..baa3a450 --- /dev/null +++ b/app/Console/Commands/DeleteInactiveUsersCommand.php @@ -0,0 +1,27 @@ +option('minutes'); + $result = $service->cleanup($minutes > 0 ? $minutes : 15); + + $this->info(sprintf( + 'Deleted %d inactive users, %d parents rows, %d orphaned user_roles.', + $result['deleted_users'], + $result['deleted_parents'], + $result['deleted_roles'] + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalculateAttendanceCommand.php b/app/Console/Commands/RecalculateAttendanceCommand.php new file mode 100644 index 00000000..f33ccec1 --- /dev/null +++ b/app/Console/Commands/RecalculateAttendanceCommand.php @@ -0,0 +1,20 @@ +rebuild(); + $this->info('Attendance summary recalculation finished. Inserted: ' . $result['inserted']); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendAbsenteesSummaryCommand.php b/app/Console/Commands/SendAbsenteesSummaryCommand.php new file mode 100644 index 00000000..6f25f29d --- /dev/null +++ b/app/Console/Commands/SendAbsenteesSummaryCommand.php @@ -0,0 +1,20 @@ +sendAbsenteesSummary(); + $this->info("Attendance absentees summary completed. Sent: {$sent}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendLatesSummaryCommand.php b/app/Console/Commands/SendLatesSummaryCommand.php new file mode 100644 index 00000000..3489ccaf --- /dev/null +++ b/app/Console/Commands/SendLatesSummaryCommand.php @@ -0,0 +1,20 @@ +sendLatesSummary(); + $this->info("Attendance lates summary completed. Sent: {$sent}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendMonthlyPaymentNotificationsCommand.php b/app/Console/Commands/SendMonthlyPaymentNotificationsCommand.php new file mode 100644 index 00000000..a2ffccf1 --- /dev/null +++ b/app/Console/Commands/SendMonthlyPaymentNotificationsCommand.php @@ -0,0 +1,56 @@ +option('force'); + $email = (string) ($this->option('email') ?? ''); + $type = (string) ($this->option('type') ?? ''); + + if ($email !== '') { + $user = User::query()->where('email', $email)->first(); + if (!$user) { + $this->error('Invalid --email provided.'); + return self::FAILURE; + } + + $result = $service->send([ + 'parent_id' => (int) $user->id, + 'type' => $type, + 'force' => true, + ]); + + $this->info('Test reminder sent for ' . $email . '.'); + return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS; + } + + if (!$force && !$this->isFirstSaturday(now())) { + $this->info('Not the first Saturday of the month. Use --force to override.'); + return self::SUCCESS; + } + + $result = $service->send([ + 'type' => $type, + 'force' => $force, + ]); + + $this->info(sprintf('Done. Sent: %d, Skipped: %d, Failed: %d.', $result['sent'], $result['skipped'], $result['failed'])); + + return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS; + } + + private function isFirstSaturday(\DateTimeInterface $dt): bool + { + return (int) $dt->format('w') === 6 && (int) $dt->format('j') <= 7; + } +} diff --git a/app/Console/Commands/SendTestPaymentNotificationCommand.php b/app/Console/Commands/SendTestPaymentNotificationCommand.php new file mode 100644 index 00000000..63002fbe --- /dev/null +++ b/app/Console/Commands/SendTestPaymentNotificationCommand.php @@ -0,0 +1,33 @@ +option('email'); + $type = (string) $this->option('type'); + + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('Usage: php artisan payments:send-test --email=addr@example.com [--type=no_payment|installment]'); + return self::FAILURE; + } + + $result = $service->send($email, $type); + if (!$result['ok']) { + $this->error('Failed to send test reminder to ' . $email . '.'); + return self::FAILURE; + } + + $this->info('Sent test reminder to ' . $email . '.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncPaypalPaymentsCommand.php b/app/Console/Commands/SyncPaypalPaymentsCommand.php new file mode 100644 index 00000000..7a5ba23f --- /dev/null +++ b/app/Console/Commands/SyncPaypalPaymentsCommand.php @@ -0,0 +1,27 @@ +option('dry-run'); + $reportOnly = (bool) $this->option('report-only'); + + $result = $service->sync($dryRun, $reportOnly); + + $this->info(sprintf('[%s] %d PayPal payments processed.', $result['mode'], $result['processed'])); + if (!empty($result['failed'])) { + $this->error(sprintf('[%s] Failed transactions: %s', $result['mode'], implode(', ', $result['failed']))); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php new file mode 100644 index 00000000..d2626f90 --- /dev/null +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -0,0 +1,115 @@ +all(), [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + if ($validator->fails()) { + return $this->respondValidationError($validator->errors()->toArray()); + } + + $email = (string) $request->input('email'); + $password = (string) $request->input('password'); + + $user = User::query()->where('email', $email)->first(); + if (!$user || !ci_password_verify($password, (string) $user->password)) { + return $this->respondError('Invalid credentials.', 401); + } + + if ((int) ($user->is_verified ?? 0) !== 1) { + return $this->respondError('Account not verified.', 403); + } + + if (strcasecmp((string) ($user->status ?? ''), 'Active') !== 0) { + return $this->respondError('Account is inactive.', 403); + } + + $jwtToken = JWTAuth::fromUser($user); + $sanctumToken = $user->createToken('api')->plainTextToken; + + return $this->respondSuccess([ + 'user' => [ + 'id' => (int) $user->id, + 'firstname' => $user->firstname, + 'lastname' => $user->lastname, + 'email' => $user->email, + ], + 'jwt' => [ + 'access_token' => $jwtToken, + 'token_type' => 'bearer', + 'expires_in' => (int) config('jwt.ttl') * 60, + ], + 'sanctum' => [ + 'access_token' => $sanctumToken, + 'token_type' => 'bearer', + ], + ], 'Authenticated.'); + } + + public function refresh(Request $request): JsonResponse + { + try { + $newToken = JWTAuth::parseToken()->refresh(); + } catch (\Throwable $e) { + return $this->respondError('Token refresh failed.', 401); + } + + return $this->respondSuccess([ + 'access_token' => $newToken, + 'token_type' => 'bearer', + 'expires_in' => (int) config('jwt.ttl') * 60, + ], 'Token refreshed.'); + } + + public function me(): JsonResponse + { + $user = Auth::user(); + if (!$user) { + return $this->respondError('Unauthorized.', 401); + } + + return $this->respondSuccess([ + 'id' => (int) $user->id, + 'firstname' => $user->firstname, + 'lastname' => $user->lastname, + 'email' => $user->email, + ], 'OK'); + } + + public function logout(Request $request): JsonResponse + { + $token = $request->bearerToken(); + if ($token && substr_count($token, '.') === 2) { + try { + JWTAuth::setToken($token)->invalidate(); + } catch (\Throwable $e) { + // ignore invalidation errors + } + } + + $user = $request->user(); + if ($user && method_exists($user, 'currentAccessToken')) { + $current = $user->currentAccessToken(); + if ($current) { + $current->delete(); + } + } + + return $this->respondSuccess(null, 'Logged out.'); + } +} diff --git a/app/Http/Controllers/Api/Notifications/NotificationController.php b/app/Http/Controllers/Api/Notifications/NotificationController.php new file mode 100644 index 00000000..4a67b97e --- /dev/null +++ b/app/Http/Controllers/Api/Notifications/NotificationController.php @@ -0,0 +1,165 @@ +id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $payload = $request->validated(); + $result = $this->listService->listForUser($userId, $payload); + + return response()->json([ + 'ok' => true, + 'notifications' => UserNotificationResource::collection($result['notifications']), + 'pagination' => $result['pagination'], + ]); + } + + public function show(int $notificationId): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $row = $this->showService->getForUser($userId, $notificationId); + if (!$row) { + return response()->json(['ok' => false, 'message' => 'Notification not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'notification' => new UserNotificationResource($row), + ]); + } + + public function store(NotificationSendRequest $request): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + + try { + $result = $this->sendService->send($payload, $actorId); + } catch (\Throwable $e) { + Log::error('Notification send failed.', ['error' => $e->getMessage()]); + return response()->json(['ok' => false, 'message' => 'Failed to send notification.'], 500); + } + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => 'Failed to send notification.'], 422); + } + + return response()->json([ + 'ok' => true, + 'notification' => new NotificationResource($result['notification']), + 'recipient_count' => $result['recipient_count'] ?? 0, + ], 201); + } + + public function update(NotificationUpdateRequest $request, int $notificationId): JsonResponse + { + $payload = $request->validated(); + $result = $this->managementService->update($notificationId, $payload); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update notification.'], 404); + } + + return response()->json([ + 'ok' => true, + 'notification' => new NotificationResource($result['notification']), + ]); + } + + public function destroy(int $notificationId): JsonResponse + { + $deleted = $this->managementService->delete($notificationId); + if (!$deleted) { + return response()->json(['ok' => false, 'message' => 'Notification not found.'], 404); + } + + return response()->json(['ok' => true]); + } + + public function restore(int $notificationId): JsonResponse + { + $restored = $this->managementService->restore($notificationId); + if (!$restored) { + return response()->json(['ok' => false, 'message' => 'Notification not found.'], 404); + } + + return response()->json(['ok' => true]); + } + + public function markRead(int $notificationId): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $ok = $this->readService->markRead($userId, $notificationId); + if (!$ok) { + return response()->json(['ok' => false, 'message' => 'Notification not found.'], 404); + } + + return response()->json(['ok' => true]); + } + + public function active(NotificationActiveRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->activeService->list($payload['target_group'] ?? null); + + return response()->json([ + 'ok' => true, + 'notifications' => $data['notifications'], + ]); + } + + public function deleted(NotificationDeletedRequest $request): JsonResponse + { + $data = $this->deletedService->list(); + + return response()->json([ + 'ok' => true, + 'notifications' => $data['notifications'], + ]); + } +} diff --git a/app/Http/Middleware/MultiAuth.php b/app/Http/Middleware/MultiAuth.php new file mode 100644 index 00000000..ee12e081 --- /dev/null +++ b/app/Http/Middleware/MultiAuth.php @@ -0,0 +1,37 @@ +user(); + if ($sanctumUser) { + Auth::setUser($sanctumUser); + return $next($request); + } + + $token = $request->bearerToken(); + if ($token) { + try { + $jwtUser = JWTAuth::setToken($token)->authenticate(); + if ($jwtUser) { + Auth::setUser($jwtUser); + return $next($request); + } + } catch (JWTException $e) { + // fall through to unauthorized response + } + } + + return response()->json(['message' => 'Unauthorized.'], 401); + } +} diff --git a/app/Http/Requests/Notifications/NotificationActiveRequest.php b/app/Http/Requests/Notifications/NotificationActiveRequest.php new file mode 100644 index 00000000..99c38faf --- /dev/null +++ b/app/Http/Requests/Notifications/NotificationActiveRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'target_group' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Notifications/NotificationDeletedRequest.php b/app/Http/Requests/Notifications/NotificationDeletedRequest.php new file mode 100644 index 00000000..11871951 --- /dev/null +++ b/app/Http/Requests/Notifications/NotificationDeletedRequest.php @@ -0,0 +1,18 @@ +user() !== null; + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Notifications/NotificationIndexRequest.php b/app/Http/Requests/Notifications/NotificationIndexRequest.php new file mode 100644 index 00000000..33ed4818 --- /dev/null +++ b/app/Http/Requests/Notifications/NotificationIndexRequest.php @@ -0,0 +1,24 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'read' => ['nullable', 'boolean'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + 'sort_by' => ['nullable', 'string', 'in:created_at,scheduled_at,priority'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + ]; + } +} diff --git a/app/Http/Requests/Notifications/NotificationSendRequest.php b/app/Http/Requests/Notifications/NotificationSendRequest.php new file mode 100644 index 00000000..46936bb9 --- /dev/null +++ b/app/Http/Requests/Notifications/NotificationSendRequest.php @@ -0,0 +1,32 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:150'], + 'message' => ['required', 'string', 'max:5000'], + 'target_group' => ['required', 'string', 'max:50'], + 'channels' => ['required', 'array'], + 'channels.*' => ['string', 'in:in_app,email,sms'], + 'priority' => ['nullable', 'string', 'max:20'], + 'status' => ['nullable', 'string', 'max:20'], + 'action_url' => ['nullable', 'string', 'max:255'], + 'attachment_path' => ['nullable', 'string', 'max:255'], + 'scheduled_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after_or_equal:scheduled_at'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Notifications/NotificationUpdateRequest.php b/app/Http/Requests/Notifications/NotificationUpdateRequest.php new file mode 100644 index 00000000..902184f9 --- /dev/null +++ b/app/Http/Requests/Notifications/NotificationUpdateRequest.php @@ -0,0 +1,30 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:150'], + 'message' => ['sometimes', 'required', 'string', 'max:5000'], + 'target_group' => ['sometimes', 'required', 'string', 'max:50'], + 'channels' => ['sometimes', 'required', 'array'], + 'channels.*' => ['string', 'in:in_app,email,sms'], + 'priority' => ['nullable', 'string', 'max:20'], + 'status' => ['nullable', 'string', 'max:20'], + 'action_url' => ['nullable', 'string', 'max:255'], + 'attachment_path' => ['nullable', 'string', 'max:255'], + 'scheduled_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after_or_equal:scheduled_at'], + ]; + } +} diff --git a/app/Http/Resources/Notifications/NotificationResource.php b/app/Http/Resources/Notifications/NotificationResource.php new file mode 100644 index 00000000..4090a8aa --- /dev/null +++ b/app/Http/Resources/Notifications/NotificationResource.php @@ -0,0 +1,31 @@ + (int) ($this->id ?? 0), + 'title' => $this->title, + 'message' => $this->message, + 'target_group' => $this->target_group, + 'delivery_channels' => $this->delivery_channels, + 'priority' => $this->priority, + 'status' => $this->status, + 'action_url' => $this->action_url, + 'attachment_path' => $this->attachment_path, + 'scheduled_at' => optional($this->scheduled_at)->toDateTimeString(), + 'sent_at' => optional($this->sent_at)->toDateTimeString(), + 'expires_at' => optional($this->expires_at)->toDateTimeString(), + 'school_year' => $this->school_year, + 'semester' => $this->semester, + 'created_at' => optional($this->created_at)->toDateTimeString(), + 'updated_at' => optional($this->updated_at)->toDateTimeString(), + ]; + } +} diff --git a/app/Http/Resources/Notifications/UserNotificationResource.php b/app/Http/Resources/Notifications/UserNotificationResource.php new file mode 100644 index 00000000..71e64121 --- /dev/null +++ b/app/Http/Resources/Notifications/UserNotificationResource.php @@ -0,0 +1,26 @@ +scheduled_at ?? $this['scheduled_at'] ?? null; + $expiresAt = $this->expires_at ?? $this['expires_at'] ?? null; + + return [ + 'notification_id' => (int) ($this->notification_id ?? $this['notification_id'] ?? 0), + 'title' => $this->title ?? $this['title'] ?? null, + 'message' => $this->message ?? $this['message'] ?? null, + 'priority' => $this->priority ?? $this['priority'] ?? null, + 'scheduled_at' => $scheduledAt ? date('Y-m-d H:i:s', strtotime((string) $scheduledAt)) : null, + 'expires_at' => $expiresAt ? date('Y-m-d H:i:s', strtotime((string) $expiresAt)) : null, + 'target_group' => $this->target_group ?? $this['target_group'] ?? null, + 'is_read' => (bool) ($this->is_read ?? $this['is_read'] ?? false), + ]; + } +} diff --git a/app/Libraries/StaffTimeOffLinkService.php b/app/Libraries/StaffTimeOffLinkService.php index e16edb20..e44fc38d 100644 --- a/app/Libraries/StaffTimeOffLinkService.php +++ b/app/Libraries/StaffTimeOffLinkService.php @@ -2,15 +2,21 @@ namespace App\Libraries; +use App\Services\Staff\StaffTimeOffLinkService as StaffTimeOffLinkTokenService; + class StaffTimeOffLinkService { + public function __construct(private StaffTimeOffLinkTokenService $service = new StaffTimeOffLinkTokenService()) + { + } + public function createToken(array $payload): string { - $json = json_encode($payload); - if ($json === false) { - return ''; - } + return $this->service->createToken($payload); + } - return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + public function parseToken(?string $token): ?array + { + return $this->service->parseToken($token); } } diff --git a/app/Listeners/AttendanceConsequenceListener.php b/app/Listeners/AttendanceConsequenceListener.php new file mode 100644 index 00000000..1238751c --- /dev/null +++ b/app/Listeners/AttendanceConsequenceListener.php @@ -0,0 +1,27 @@ +service->sendFollowUp($payload); + } + + public function finalWarning(array $payload): void + { + $this->service->sendFinalWarning($payload); + } + + public function dismissal(array $payload): void + { + $this->service->sendDismissal($payload); + } +} diff --git a/app/Listeners/BelowSixtyEmailListener.php b/app/Listeners/BelowSixtyEmailListener.php new file mode 100644 index 00000000..0e9c54b9 --- /dev/null +++ b/app/Listeners/BelowSixtyEmailListener.php @@ -0,0 +1,17 @@ +service->send($payload); + } +} diff --git a/app/Listeners/SchoolEventListener.php b/app/Listeners/SchoolEventListener.php new file mode 100644 index 00000000..3ed583d5 --- /dev/null +++ b/app/Listeners/SchoolEventListener.php @@ -0,0 +1,147 @@ +enrollment->admissionUnderReview($data, $studentdata); + } + + public function handlePaymentPending(array $data, array $studentdata = []): void + { + $this->enrollment->paymentPending($data, $studentdata); + } + + public function handleStudentEnrolled(array $data, array $studentdata = []): void + { + $this->enrollment->studentEnrolled($data, $studentdata); + } + + public function handleWithdrawUnderReview(array $data, array $studentdata = []): void + { + $this->enrollment->withdrawUnderReview($data, $studentdata); + } + + public function handleRefundPending(array $data, array $studentdata = []): void + { + $this->enrollment->refundPending($data, $studentdata); + } + + public function handleWithdrawn(array $data, array $studentdata = []): void + { + $this->enrollment->withdrawn($data, $studentdata); + } + + public function handleDenied(array $data, array $studentdata = []): void + { + $this->enrollment->denied($data, $studentdata); + } + + public function handleWaitlist(array $data, array $studentdata = []): void + { + $this->enrollment->waitlist($data, $studentdata); + } + + public function handleEnrollmentStatusChanged(array $data, array $studentdata = []): void + { + $this->enrollment->enrollmentStatusChanged($data, $studentdata); + } + + public function handlePaymentReceived(array $data, array $studentdata = []): void + { + $this->payments->paymentReceived($data, $studentdata); + } + + public function handleExtraCharge(array $data, array $studentdata = []): void + { + $this->payments->extraCharge($data, $studentdata); + } + + public function handleNewAccountAdded(array $userData): void + { + $this->accounts->newAccountAdded($userData); + } + + public function handleStudentRegistered(array $data): void + { + $this->accounts->studentRegistered($data); + } + + public function handleDeleteUnverifiedUser(array $user): void + { + $this->accounts->deleteUnverifiedUser($user); + } + + public function handleUserRegistered(array $user): void + { + $this->accounts->userRegistered($user); + } + + public function handleUserProfileUpdated(array $user): void + { + $this->accounts->userProfileUpdated($user); + } + + public function handleUserDeactivated(array $user): void + { + $this->accounts->userDeactivated($user); + } + + public function handleScoresPosted(array $data): void + { + $this->accounts->scoresPosted($data); + } + + public function handleFinalScoreReleased(array $data): void + { + $this->accounts->finalScoreReleased($data); + } + + public function handleStudentMarkedAbsent(array $record): void + { + $this->accounts->studentMarkedAbsent($record); + } + + public function handleStudentMarkedLate(array $record): void + { + $this->accounts->studentMarkedLate($record); + } + + public function handlePaymentMissed(array $user): void + { + $this->accounts->paymentMissed($user); + } + + public function handleClassScheduleUpdated(array $data): void + { + $this->accounts->classScheduleUpdated($data); + } + + public function handleNewMessageReceived(array $data): void + { + $this->accounts->newMessageReceived($data); + } + + public function handleSystemAnnouncementPosted(array $data): void + { + $this->accounts->systemAnnouncementPosted($data); + } + + public function handleCustomNotification(array $data): void + { + $this->accounts->customNotification($data); + } +} diff --git a/app/Listeners/WhatsappInviteListener.php b/app/Listeners/WhatsappInviteListener.php new file mode 100644 index 00000000..da7c4409 --- /dev/null +++ b/app/Listeners/WhatsappInviteListener.php @@ -0,0 +1,18 @@ +service->send($event->payload); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6a301610..28f26a46 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,9 +7,11 @@ use App\Services\Attendance\AttendanceRecordSyncService; use App\Services\Attendance\SemesterRangeService; use App\Services\Attendance\StudentAttendanceWriterService; use App\Services\Attendance\TeacherAttendanceSubmissionService; +use App\Services\Attendance\AttendanceAutoPublishService; use App\Services\Invoices\InvoiceConfigService; use App\Services\Invoices\InvoiceGradeService; use App\Services\Invoices\InvoiceTuitionService; +use App\Services\Staff\StaffTimeOffLinkService; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Routing\Redirector; use Illuminate\Support\ServiceProvider; @@ -23,6 +25,8 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(AttendanceRecordSyncService::class); $this->app->singleton(StudentAttendanceWriterService::class); $this->app->singleton(TeacherAttendanceSubmissionService::class); + $this->app->singleton(AttendanceAutoPublishService::class); + $this->app->singleton(StaffTimeOffLinkService::class); $this->app->singleton(InvoiceConfigService::class); $this->app->bind(InvoiceGradeService::class, function ($app) { diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 00000000..fa46a5cd --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,109 @@ + [ + AttendanceConsequenceListener::class . '@followUp', + ], + 'attendance.final_warning' => [ + AttendanceConsequenceListener::class . '@finalWarning', + ], + 'attendance.dismissal' => [ + AttendanceConsequenceListener::class . '@dismissal', + ], + 'below60.email' => [ + BelowSixtyEmailListener::class, + ], + 'admissionUnderReview' => [ + SchoolEventListener::class . '@handleAdmissionUnderReview', + ], + 'paymentPending' => [ + SchoolEventListener::class . '@handlePaymentPending', + ], + 'studentEnrolled' => [ + SchoolEventListener::class . '@handleStudentEnrolled', + ], + 'withdrawUnderReview' => [ + SchoolEventListener::class . '@handleWithdrawUnderReview', + ], + 'refundPending' => [ + SchoolEventListener::class . '@handleRefundPending', + ], + 'withdrawn' => [ + SchoolEventListener::class . '@handleWithdrawn', + ], + 'denied' => [ + SchoolEventListener::class . '@handleDenied', + ], + 'waitlist' => [ + SchoolEventListener::class . '@handleWaitlist', + ], + 'enrollmentStatusChanged' => [ + SchoolEventListener::class . '@handleEnrollmentStatusChanged', + ], + 'paymentReceived' => [ + SchoolEventListener::class . '@handlePaymentReceived', + ], + 'extraCharge' => [ + SchoolEventListener::class . '@handleExtraCharge', + ], + 'newAccountAdded' => [ + SchoolEventListener::class . '@handleNewAccountAdded', + ], + 'studentRegistered' => [ + SchoolEventListener::class . '@handleStudentRegistered', + ], + 'delete_unverified_user' => [ + SchoolEventListener::class . '@handleDeleteUnverifiedUser', + ], + 'userRegistered' => [ + SchoolEventListener::class . '@handleUserRegistered', + ], + 'userProfileUpdated' => [ + SchoolEventListener::class . '@handleUserProfileUpdated', + ], + 'userDeactivated' => [ + SchoolEventListener::class . '@handleUserDeactivated', + ], + 'scoresPosted' => [ + SchoolEventListener::class . '@handleScoresPosted', + ], + 'finalScoreReleased' => [ + SchoolEventListener::class . '@handleFinalScoreReleased', + ], + 'studentMarkedAbsent' => [ + SchoolEventListener::class . '@handleStudentMarkedAbsent', + ], + 'studentMarkedLate' => [ + SchoolEventListener::class . '@handleStudentMarkedLate', + ], + 'paymentMissed' => [ + SchoolEventListener::class . '@handlePaymentMissed', + ], + 'classScheduleUpdated' => [ + SchoolEventListener::class . '@handleClassScheduleUpdated', + ], + 'newMessageReceived' => [ + SchoolEventListener::class . '@handleNewMessageReceived', + ], + 'systemAnnouncementPosted' => [ + SchoolEventListener::class . '@handleSystemAnnouncementPosted', + ], + 'customNotification' => [ + SchoolEventListener::class . '@handleCustomNotification', + ], + WhatsappInvitesSend::class => [ + WhatsappInviteListener::class, + ], + ]; +} diff --git a/app/Services/Administrator/AdministratorAbsenceService.php b/app/Services/Administrator/AdministratorAbsenceService.php index e17cc97d..c47ae826 100644 --- a/app/Services/Administrator/AdministratorAbsenceService.php +++ b/app/Services/Administrator/AdministratorAbsenceService.php @@ -2,7 +2,7 @@ namespace App\Services\Administrator; -use App\Libraries\StaffTimeOffLinkService; +use App\Services\Staff\StaffTimeOffLinkService; use App\Models\StaffAttendance; use App\Models\User; use App\Services\SemesterRangeService; @@ -224,4 +224,4 @@ class AdministratorAbsenceService Log::error('Failed to send TimeOff email (admin): ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Services/Attendance/AttendanceAutoPublishJobService.php b/app/Services/Attendance/AttendanceAutoPublishJobService.php new file mode 100644 index 00000000..88e763af --- /dev/null +++ b/app/Services/Attendance/AttendanceAutoPublishJobService.php @@ -0,0 +1,49 @@ +autoPublish->timezone(); + $now = $now ? $now->setTimezone($zone) : new DateTimeImmutable('now', $zone); + $nowStr = $now->format('Y-m-d H:i:s'); + + $cutoffDate = $this->autoPublish->secondSundayBackwardDate($now); + + $query = DB::table('attendance_day') + ->where('status', 'submitted') + ->where(function ($q) use ($nowStr, $cutoffDate) { + $q->where('auto_publish_at', '<=', $nowStr) + ->orWhere(function ($sub) use ($cutoffDate) { + $sub->whereNull('auto_publish_at') + ->where('date', '<=', $cutoffDate); + }); + }); + + $count = (clone $query)->count(); + if ($count > 0) { + $query->update([ + 'status' => 'published', + 'published_by' => 0, + 'published_at' => $nowStr, + 'updated_at' => $nowStr, + ]); + } + + return [ + 'published' => $count, + 'checked_at' => $nowStr, + 'timezone' => $zone->getName(), + ]; + } +} diff --git a/app/Services/Attendance/AttendanceAutoPublishService.php b/app/Services/Attendance/AttendanceAutoPublishService.php new file mode 100644 index 00000000..c595e1e8 --- /dev/null +++ b/app/Services/Attendance/AttendanceAutoPublishService.php @@ -0,0 +1,46 @@ +attendance['timezone'] ?? null) + : null); + + return new DateTimeZone($tz ?: date_default_timezone_get()); + } + + public function secondSundayAfterEndOfDay(string $ymd, ?string $tz = null): string + { + $zone = $this->timezone($tz); + $day = new DateTimeImmutable($ymd . ' 00:00:00', $zone); + + $weekday = (int) $day->format('w'); + $daysToNextSunday = (7 - $weekday) % 7; + if ($daysToNextSunday === 0) { + $daysToNextSunday = 7; + } + + $firstSunday = $day->modify("+{$daysToNextSunday} days"); + $secondSunday = $firstSunday->modify('+7 days'); + + return $secondSunday->setTime(23, 59, 59)->format('Y-m-d H:i:s'); + } + + public function secondSundayBackwardDate(DateTimeImmutable $now, ?string $tz = null): string + { + $zone = $this->timezone($tz); + $inZone = $now->setTimezone($zone); + $weekday = (int) $inZone->format('w'); + $thisSunday = $inZone->setTime(0, 0, 0)->modify("-{$weekday} days"); + $twoBack = $thisSunday->modify('-14 days'); + + return $twoBack->format('Y-m-d'); + } +} diff --git a/app/Services/Attendance/AttendanceConsequenceService.php b/app/Services/Attendance/AttendanceConsequenceService.php new file mode 100644 index 00000000..a3882d40 --- /dev/null +++ b/app/Services/Attendance/AttendanceConsequenceService.php @@ -0,0 +1,149 @@ +send( + 'follow_up', + $payload, + 'Attendance Follow Up', + 'emails/follow_up', + 'info' + ); + } + + public function sendFinalWarning(array $payload): array + { + return $this->send( + 'final_warning', + $payload, + 'Final Warning: Attendance Issue', + 'emails/final_warning', + 'warning' + ); + } + + public function sendDismissal(array $payload): array + { + return $this->send( + 'dismissal', + $payload, + 'Notice of Dismissal', + 'emails/dismissal', + 'error' + ); + } + + private function send( + string $type, + array $payload, + string $defaultSubject, + string $viewName, + string $logLevel + ): array { + $studentName = trim((string) ($payload['student']['firstname'] ?? '') . ' ' . (string) ($payload['student']['lastname'] ?? '')); + $parentEmail = (string) ($payload['parent']['email'] ?? ''); + $parentName = (string) ($payload['parent']['name'] ?? ''); + $teacherName = trim((string) ($payload['teacher']['firstname'] ?? '') . ' ' . (string) ($payload['teacher']['lastname'] ?? '')); + $className = (string) ($payload['class']['class_section_name'] ?? ''); + $semester = (string) ($payload['semester'] ?? ''); + $schoolYear = (string) ($payload['school_year'] ?? ''); + $dateYmd = (string) ($payload['date'] ?? ''); + $dateFmt = $dateYmd !== '' ? date('m-d-Y', strtotime($dateYmd)) : ''; + $sentAt = $payload['now'] ?? utc_now(); + + if ($parentEmail === '') { + Log::warning("Attendance {$type}: missing parent email", [ + 'student_id' => $payload['student_id'] ?? null, + ]); + return ['ok' => false, 'message' => 'Missing parent email.']; + } + + $subject = (string) ($payload['subject'] ?? $this->buildSubject($defaultSubject, $studentName, $dateFmt)); + + $emailData = [ + 'title' => $subject, + 'student_name' => $studentName, + 'parent_name' => $parentName, + 'teacher_name' => $teacherName, + 'class_section_name' => $className, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'date_fmt' => $dateFmt, + 'sent_at' => $sentAt, + ]; + + $html = trim((string) ($payload['html'] ?? '')); + if ($html === '') { + try { + $html = view($viewName, $emailData, ['saveData' => true]); + } catch (\Throwable $e) { + $html = '
' . e($subject) . '
'; + } + } + + $ok = false; + try { + $ok = $this->emailService->send($parentEmail, $subject, $html, 'attendance'); + } catch (\Throwable $e) { + Log::error('Attendance email send failed: ' . $e->getMessage()); + } + + $summary = $this->buildInAppSummary($type, $studentName, $dateFmt); + $parentUserId = (int) ($payload['parent']['user_id'] ?? 0); + if ($parentUserId > 0) { + $this->notifier->notifyUser( + $parentUserId, + $subject, + $summary, + ['in_app'], + 'attendance', + 'parent' + ); + } + + if (!empty($payload['tracking_id'])) { + AttendanceTracking::markAsNotified((int) $payload['tracking_id']); + } + + Log::log($logLevel, "Attendance {$type} email " . ($ok ? 'sent' : 'failed'), [ + 'student_id' => $payload['student_id'] ?? null, + 'recipient' => $parentEmail, + ]); + + return ['ok' => $ok]; + } + + private function buildSubject(string $base, string $studentName, string $dateFmt): string + { + $name = $studentName !== '' ? " — {$studentName}" : ''; + $date = $dateFmt !== '' ? " ({$dateFmt})" : ''; + return $base . $name . $date; + } + + private function buildInAppSummary(string $type, string $studentName, string $dateFmt): string + { + $who = $studentName !== '' ? $studentName : 'a student'; + $when = $dateFmt !== '' ? " on {$dateFmt}" : ''; + + return match ($type) { + 'dismissal' => "Dismissal notice sent for {$who}{$when}.", + 'final_warning' => "Final warning sent for {$who}{$when}.", + default => "Follow-up notice sent for {$who}{$when}.", + }; + } +} diff --git a/app/Services/Attendance/AttendanceDailySummaryService.php b/app/Services/Attendance/AttendanceDailySummaryService.php new file mode 100644 index 00000000..0a838a2c --- /dev/null +++ b/app/Services/Attendance/AttendanceDailySummaryService.php @@ -0,0 +1,97 @@ +fetchTodaySummary('absent'); + return $this->notifyParents($rows, 'absent'); + } + + public function sendLatesSummary(): int + { + $rows = $this->fetchTodaySummary('late'); + return $this->notifyParents($rows, 'late'); + } + + private function fetchTodaySummary(string $type): array + { + $start = now()->startOfDay(); + $endEx = now()->addDay()->startOfDay(); + + $query = DB::table('attendance_data as ad') + ->join('students as s', 's.id', '=', 'ad.student_id') + ->leftJoin('users as u', 'u.id', '=', 's.parent_id') + ->select( + 'ad.date', + 's.id as student_id', + 's.firstname as student_firstname', + 's.lastname as student_lastname', + 'u.id as parent_id', + 'u.email as parent_email', + 'u.firstname as parent_firstname', + 'u.lastname as parent_lastname' + ) + ->where('ad.date', '>=', $start) + ->where('ad.date', '<', $endEx); + + if ($type === 'absent') { + $query->where(function ($w) { + $w->whereIn(DB::raw("UPPER(TRIM(ad.status))"), ['ABSENT', 'ABS', 'A']) + ->orWhereRaw("UPPER(TRIM(ad.status)) LIKE 'ABS%'"); + }); + } else { + $query->where(function ($w) { + $w->whereIn(DB::raw("UPPER(TRIM(ad.status))"), ['LATE', 'L']) + ->orWhereRaw("UPPER(TRIM(ad.status)) LIKE 'LATE%'"); + }); + } + + return $query->get()->map(fn ($row) => (array) $row)->all(); + } + + private function notifyParents(array $rows, string $type): int + { + $sent = 0; + foreach ($rows as $row) { + $parentId = (int) ($row['parent_id'] ?? 0); + if ($parentId <= 0) { + continue; + } + + $studentName = trim((string) ($row['student_firstname'] ?? '') . ' ' . (string) ($row['student_lastname'] ?? '')); + $date = substr((string) ($row['date'] ?? ''), 0, 10); + + $message = sprintf( + 'Daily Attendance Update: %s was marked %s on %s.', + $studentName !== '' ? $studentName : 'Your student', + $type, + $date + ); + + $this->notifier->notifyUser($parentId, 'Daily Attendance Update', $message, ['in_app'], 'attendance', 'parent'); + + $email = (string) ($row['parent_email'] ?? ''); + if ($email !== '') { + $body = '' . e($message) . '
'; + $this->emailService->send($email, 'Daily Attendance Update', $body, 'attendance'); + } + + $sent++; + } + + return $sent; + } +} diff --git a/app/Services/Attendance/AttendancePolicyService.php b/app/Services/Attendance/AttendancePolicyService.php index 999264cd..af94f50f 100644 --- a/app/Services/Attendance/AttendancePolicyService.php +++ b/app/Services/Attendance/AttendancePolicyService.php @@ -2,6 +2,7 @@ namespace App\Services\Attendance; +use App\Models\Role; use Illuminate\Contracts\Auth\Authenticatable; class AttendancePolicyService @@ -42,24 +43,14 @@ class AttendancePolicyService public function isAdminLike(array $roles, array $permissions = []): bool { $roles = array_map([$this, 'normalizeRole'], $roles); + $adminTokens = $this->adminRoleTokens(); - $excluded = [ - 'parent', - 'guest', - 'teacher', - 'teacher_assistant', - 'assistant_teacher', - 'ta', - ]; - - foreach ($roles as $role) { - if ($role !== '' && !in_array($role, $excluded, true)) { - return true; - } + if (!empty($adminTokens) && count(array_intersect($roles, $adminTokens)) > 0) { + return true; } foreach ($permissions as $permission) { - $permission = strtolower(trim((string)$permission)); + $permission = strtolower(trim((string) $permission)); if (str_contains($permission, 'attendance.manage') || str_contains($permission, 'attendance.admin')) { return true; } @@ -72,4 +63,66 @@ class AttendancePolicyService { return str_replace([' ', '-'], '_', strtolower(trim((string)$role))); } -} \ No newline at end of file + + public function canManageEarlyDismissals(array $userRoles): bool + { + $normalized = array_map([$this, 'normalizeRole'], $userRoles); + $adminTokens = $this->adminRoleTokens(); + $isAdminLike = count(array_intersect($normalized, $adminTokens)) > 0; + $isTeacher = in_array('teacher', $normalized, true) || in_array('teacher_assistant', $normalized, true); + + return $isAdminLike || $isTeacher; + } + + private function adminRoleTokens(): array + { + static $cache = null; + if (is_array($cache)) { + return $cache; + } + + $tokens = []; + $normalize = fn (string $value): string => $this->normalizeRole($value); + + try { + $rows = Role::query() + ->where('is_active', 1) + ->get(['name', 'slug']); + + $excluded = array_map($normalize, [ + 'guest', + 'parent', + 'student', + 'teacher', + 'teacher_assistant', + 'ta', + ]); + + foreach ($rows as $row) { + $name = $normalize((string) ($row->name ?? '')); + $slug = $normalize((string) ($row->slug ?? $name)); + if ($name === '' && $slug === '') { + continue; + } + if (in_array($slug, $excluded, true) || in_array($name, $excluded, true)) { + continue; + } + if ($name !== '') { + $tokens[$name] = true; + } + if ($slug !== '') { + $tokens[$slug] = true; + } + } + } catch (\Throwable) { + // ignore and fall back to defaults + } + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $fallback) { + $tokens[$normalize($fallback)] = true; + } + + $cache = array_keys($tokens); + return $cache; + } +} diff --git a/app/Services/Attendance/AttendanceSummaryRebuildService.php b/app/Services/Attendance/AttendanceSummaryRebuildService.php new file mode 100644 index 00000000..5752aec9 --- /dev/null +++ b/app/Services/Attendance/AttendanceSummaryRebuildService.php @@ -0,0 +1,63 @@ +truncate(); + + $rows = DB::table('attendance_data') + ->orderBy('student_id') + ->orderBy('school_year') + ->orderBy('semester') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + if (empty($rows)) { + return ['inserted' => 0]; + } + + $summary = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $schoolYear = (string) ($row['school_year'] ?? ''); + $semester = (string) ($row['semester'] ?? ''); + $status = strtolower((string) ($row['status'] ?? '')); + + $key = $studentId . '-' . $schoolYear . '-' . $semester; + if (!isset($summary[$key])) { + $summary[$key] = [ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'class_section_id' => $row['class_section_id'] ?? null, + 'school_id' => $row['school_id'] ?? null, + 'total_presence' => 0, + 'total_absence' => 0, + 'total_late' => 0, + 'total_attendance' => 0, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]; + } + + $summary[$key]['total_attendance']++; + if ($status === 'present') { + $summary[$key]['total_presence']++; + } elseif ($status === 'absent') { + $summary[$key]['total_absence']++; + } elseif ($status === 'late') { + $summary[$key]['total_late']++; + } + } + + DB::table('attendance_record')->insert(array_values($summary)); + + return ['inserted' => count($summary)]; + } +} diff --git a/app/Services/Attendance/TeacherAttendanceSubmissionService.php b/app/Services/Attendance/TeacherAttendanceSubmissionService.php index c03c68c6..636b28ca 100644 --- a/app/Services/Attendance/TeacherAttendanceSubmissionService.php +++ b/app/Services/Attendance/TeacherAttendanceSubmissionService.php @@ -2,7 +2,6 @@ namespace App\Services\Attendance; -use App\Libraries\AttendanceAutoPublish; use App\Models\AttendanceData; use App\Models\AttendanceDay; use App\Models\Student; @@ -21,6 +20,7 @@ class TeacherAttendanceSubmissionService protected Student $student, protected AttendancePolicyService $attendancePolicyService, protected StudentAttendanceWriterService $studentAttendanceWriterService, + protected AttendanceAutoPublishService $attendanceAutoPublishService, ) {} public function submit( @@ -201,7 +201,7 @@ class TeacherAttendanceSubmissionService ]); } - $autoPublishAt = AttendanceAutoPublish::secondSundayAfterEndOfDay($attendDate); + $autoPublishAt = $this->attendanceAutoPublishService->secondSundayAfterEndOfDay($attendDate); $dayRow->update([ 'status' => 'submitted', @@ -217,4 +217,4 @@ class TeacherAttendanceSubmissionService 'message' => 'Attendance submitted successfully.', ]; } -} \ No newline at end of file +} diff --git a/app/Services/Auth/PasswordResetCleanupService.php b/app/Services/Auth/PasswordResetCleanupService.php new file mode 100644 index 00000000..02a004ba --- /dev/null +++ b/app/Services/Auth/PasswordResetCleanupService.php @@ -0,0 +1,17 @@ +subDays($days)->toDateTimeString(); + + return PasswordResetRequest::query() + ->where('requested_at', '<', $threshold) + ->delete(); + } +} diff --git a/app/Services/Grading/BelowSixtyEmailService.php b/app/Services/Grading/BelowSixtyEmailService.php new file mode 100644 index 00000000..677b21e7 --- /dev/null +++ b/app/Services/Grading/BelowSixtyEmailService.php @@ -0,0 +1,114 @@ + false, 'message' => 'Missing student id.']; + } + + $rows = DB::select( + "SELECT u.firstname, u.lastname, u.email, fg.is_primary + FROM family_students fs + JOIN family_guardians fg ON fg.family_id = fs.family_id + JOIN users u ON u.id = fg.user_id + WHERE fs.student_id = ? + AND fg.receive_emails = 1 + AND u.email IS NOT NULL AND u.email != '' + ORDER BY fg.is_primary DESC, u.lastname, u.firstname", + [$studentId] + ); + + if (empty($rows)) { + Log::warning('Below60Email: no guardian emails', ['student_id' => $studentId]); + return ['ok' => false, 'message' => 'No guardian email addresses.']; + } + + $emails = []; + foreach ($rows as $row) { + $email = trim((string) ($row->email ?? '')); + if ($email !== '') { + $emails[$email] = true; + } + } + $emails = array_keys($emails); + + $primary = $rows[0] ?? null; + $parentName = ''; + if ($primary) { + $parentName = trim((string) ($primary->firstname ?? '') . ' ' . (string) ($primary->lastname ?? '')); + } + + $subject = (string) ($payload['subject'] ?? ''); + if ($subject === '') { + $subject = 'Student Performance Alert'; + if ($studentName !== '') { + $subject .= ' — ' . $studentName; + } + if ($semester !== '' || $schoolYear !== '') { + $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')'; + } + } + + $emailData = [ + 'title' => $subject, + 'parent_name' => $parentName !== '' ? $parentName : 'Parent/Guardian', + 'student_name' => $studentName !== '' ? $studentName : 'your student', + 'class_section_name' => $classSection, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'scores' => $scores, + 'comment' => $comment, + 'sent_at' => utc_now(), + ]; + + $html = trim((string) ($payload['html'] ?? '')); + if ($html === '') { + try { + $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); + } catch (\Throwable $e) { + $html = '' . e($subject) . '
'; + } + } + + $sent = 0; + foreach ($emails as $to) { + $ok = $this->emailService->send($to, $subject, $html, 'general'); + if ($ok) { + $sent++; + } + } + + Log::info('Below60Email dispatch', [ + 'student_id' => $studentId, + 'sent' => $sent, + 'recipients' => count($emails), + ]); + + return [ + 'ok' => $sent > 0, + 'sent' => $sent, + 'recipients' => count($emails), + ]; + } +} diff --git a/app/Services/Grading/GradingBelowSixtyService.php b/app/Services/Grading/GradingBelowSixtyService.php index 8c9cca94..e544e501 100644 --- a/app/Services/Grading/GradingBelowSixtyService.php +++ b/app/Services/Grading/GradingBelowSixtyService.php @@ -138,7 +138,7 @@ class GradingBelowSixtyService public function sendEmail(array $payload): void { - Event::dispatch('below60.email', $payload); + Event::dispatch('below60.email', [$payload]); } public function updateStatus(int $studentId, string $semester, string $schoolYear, string $status, string $note, ?int $userId): void diff --git a/app/Services/Notifications/NotificationActiveService.php b/app/Services/Notifications/NotificationActiveService.php new file mode 100644 index 00000000..fd883673 --- /dev/null +++ b/app/Services/Notifications/NotificationActiveService.php @@ -0,0 +1,34 @@ + $row['id'] ?? null, + 'title' => $row['title'] ?? null, + 'message' => $row['message'] ?? null, + 'priority' => $row['priority'] ?? null, + 'scheduled_at' => $row['scheduled_at'] ?? null, + 'expires_at' => $expiresAt, + 'created_at' => $row['created_at'] ?? null, + 'target_group' => $row['target_group'] ?? null, + 'isExpired' => $isExpired, + ]; + }, $rows ?? []); + + return ['notifications' => $notifications]; + } +} diff --git a/app/Services/Notifications/NotificationCleanupService.php b/app/Services/Notifications/NotificationCleanupService.php new file mode 100644 index 00000000..b842f4f8 --- /dev/null +++ b/app/Services/Notifications/NotificationCleanupService.php @@ -0,0 +1,13 @@ + $row['id'] ?? null, + 'title' => $row['title'] ?? null, + 'message' => $row['message'] ?? null, + 'priority' => $row['priority'] ?? null, + 'scheduled_at' => $row['scheduled_at'] ?? null, + 'expires_at' => $row['expires_at'] ?? null, + 'deleted_at' => $row['deleted_at'] ?? null, + ]; + }, $rows ?? []); + + return ['notifications' => $notifications]; + } +} diff --git a/app/Services/Notifications/NotificationDispatchService.php b/app/Services/Notifications/NotificationDispatchService.php new file mode 100644 index 00000000..058e1e93 --- /dev/null +++ b/app/Services/Notifications/NotificationDispatchService.php @@ -0,0 +1,23 @@ + $to, + 'subject' => $subject, + ]); + } + + public function sendSms(string $phone, string $message): void + { + Log::info('Notification SMS queued.', [ + 'phone' => $phone, + ]); + } +} diff --git a/app/Services/Notifications/NotificationManagementService.php b/app/Services/Notifications/NotificationManagementService.php new file mode 100644 index 00000000..d692725b --- /dev/null +++ b/app/Services/Notifications/NotificationManagementService.php @@ -0,0 +1,47 @@ +find($notificationId); + if (!$notification) { + return ['ok' => false, 'message' => 'Notification not found.']; + } + + $notification->update([ + 'title' => $payload['title'] ?? $notification->title, + 'message' => $payload['message'] ?? $notification->message, + 'target_group' => $payload['target_group'] ?? $notification->target_group, + 'delivery_channels' => $payload['channels'] ?? $notification->delivery_channels, + 'priority' => $payload['priority'] ?? $notification->priority, + 'status' => $payload['status'] ?? $notification->status, + 'action_url' => $payload['action_url'] ?? $notification->action_url, + 'attachment_path' => $payload['attachment_path'] ?? $notification->attachment_path, + 'scheduled_at' => $payload['scheduled_at'] ?? $notification->scheduled_at, + 'expires_at' => $payload['expires_at'] ?? $notification->expires_at, + 'updated_at' => utc_now(), + ]); + + return ['ok' => true, 'notification' => $notification]; + } + + public function delete(int $notificationId): bool + { + $notification = Notification::query()->find($notificationId); + if (!$notification) { + return false; + } + + return (bool) $notification->delete(); + } + + public function restore(int $notificationId): bool + { + return Notification::restoreNotification($notificationId); + } +} diff --git a/app/Services/Notifications/NotificationReadService.php b/app/Services/Notifications/NotificationReadService.php new file mode 100644 index 00000000..a8aef10a --- /dev/null +++ b/app/Services/Notifications/NotificationReadService.php @@ -0,0 +1,25 @@ +where('user_id', $userId) + ->where('notification_id', $notificationId) + ->first(); + + if (!$row) { + return false; + } + + $row->is_read = true; + $row->save(); + + return true; + } +} diff --git a/app/Services/Notifications/NotificationRecipientService.php b/app/Services/Notifications/NotificationRecipientService.php new file mode 100644 index 00000000..a857e941 --- /dev/null +++ b/app/Services/Notifications/NotificationRecipientService.php @@ -0,0 +1,14 @@ +create([ + 'title' => $payload['title'], + 'message' => $payload['message'], + 'target_group' => $payload['target_group'], + 'delivery_channels' => $channels, + 'priority' => $payload['priority'] ?? null, + 'status' => $payload['status'] ?? null, + 'action_url' => $payload['action_url'] ?? null, + 'attachment_path' => $payload['attachment_path'] ?? null, + 'scheduled_at' => $payload['scheduled_at'] ?? now(), + 'sent_at' => in_array('in_app', $channels, true) ? now() : null, + 'expires_at' => $payload['expires_at'] ?? null, + 'school_year' => $payload['school_year'] ?? null, + 'semester' => $payload['semester'] ?? null, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + + $users = $this->recipients->getRecipients((string) $payload['target_group']); + $created = 0; + + foreach ($users as $user) { + UserNotification::query()->create([ + 'notification_id' => (int) $notification->id, + 'user_id' => (int) ($user['id'] ?? 0), + 'is_read' => false, + 'delivered' => in_array('in_app', $channels, true), + 'delivered_at' => in_array('in_app', $channels, true) ? now() : null, + 'school_year' => $payload['school_year'] ?? null, + 'semester' => $payload['semester'] ?? null, + ]); + $created++; + + if (in_array('email', $channels, true) && !empty($user['email'])) { + $this->dispatcher->sendEmail((string) $user['email'], (string) $payload['title'], (string) $payload['message']); + } + + if (in_array('sms', $channels, true) && !empty($user['phone'])) { + $this->dispatcher->sendSms((string) $user['phone'], (string) $payload['message']); + } + } + + Log::info('Notification dispatched.', [ + 'notification_id' => (int) $notification->id, + 'target_group' => $payload['target_group'], + 'channels' => $channels, + 'recipients' => $created, + 'actor_id' => $actorId, + ]); + + return [ + 'ok' => true, + 'notification' => $notification, + 'recipient_count' => $created, + ]; + }); + } +} diff --git a/app/Services/Notifications/NotificationShowService.php b/app/Services/Notifications/NotificationShowService.php new file mode 100644 index 00000000..b356e442 --- /dev/null +++ b/app/Services/Notifications/NotificationShowService.php @@ -0,0 +1,28 @@ +select([ + 'user_notifications.*', + 'notifications.title', + 'notifications.message', + 'notifications.priority', + 'notifications.scheduled_at', + 'notifications.expires_at', + 'notifications.target_group', + ]) + ->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id') + ->where('user_notifications.user_id', $userId) + ->where('user_notifications.notification_id', $notificationId) + ->first(); + + return $row ? $row->toArray() : null; + } +} diff --git a/app/Services/Notifications/NotificationTriggerService.php b/app/Services/Notifications/NotificationTriggerService.php new file mode 100644 index 00000000..06ecde8e --- /dev/null +++ b/app/Services/Notifications/NotificationTriggerService.php @@ -0,0 +1,23 @@ + $userId, + 'title' => $title, + 'message' => $message, + 'channels' => $channels, + ]); + } +} diff --git a/app/Services/Notifications/NotificationUserListService.php b/app/Services/Notifications/NotificationUserListService.php new file mode 100644 index 00000000..65cdd23b --- /dev/null +++ b/app/Services/Notifications/NotificationUserListService.php @@ -0,0 +1,61 @@ +select([ + 'user_notifications.*', + 'notifications.title', + 'notifications.message', + 'notifications.priority', + 'notifications.scheduled_at', + 'notifications.expires_at', + 'notifications.target_group', + ]) + ->join('notifications', 'notifications.id', '=', 'user_notifications.notification_id') + ->where('user_notifications.user_id', $userId); + + if (isset($filters['read'])) { + $read = filter_var($filters['read'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($read !== null) { + $query->where('user_notifications.is_read', $read ? 1 : 0); + } + } + + $paginator = $query + ->orderBy('notifications.' . $sortBy, $sortDir) + ->paginate($perPage, ['*'], 'page', $page); + + return [ + 'notifications' => $paginator, + 'pagination' => $this->paginationPayload($paginator), + ]; + } + + private function paginationPayload(LengthAwarePaginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'total_pages' => $paginator->lastPage(), + ]; + } +} diff --git a/app/Services/Notifications/UserNotificationDispatchService.php b/app/Services/Notifications/UserNotificationDispatchService.php new file mode 100644 index 00000000..f4eadc2e --- /dev/null +++ b/app/Services/Notifications/UserNotificationDispatchService.php @@ -0,0 +1,60 @@ + false, 'message' => 'Invalid user id.']; + } + + $channels = array_values(array_unique(array_map('strtolower', (array) $channels))); + if (empty($channels)) { + $channels = ['in_app']; + } + + $schoolYear = Configuration::getConfigValueByKey('school_year'); + $semester = Configuration::getConfigValueByKey('semester'); + + $notification = Notification::query()->create([ + 'title' => $title, + 'message' => $message, + 'target_group' => $targetGroup ?: 'everyone', + 'delivery_channels' => $channels, + 'priority' => 'normal', + 'status' => 'sent', + 'scheduled_at' => now(), + 'sent_at' => now(), + 'school_year' => $schoolYear, + 'semester' => $semester, + ]); + + UserNotification::query()->create([ + 'notification_id' => (int) $notification->id, + 'user_id' => $userId, + 'is_read' => 0, + 'delivered' => in_array('in_app', $channels, true) ? 1 : 0, + 'delivered_at' => in_array('in_app', $channels, true) ? now() : null, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]); + + return [ + 'ok' => true, + 'notification_id' => (int) $notification->id, + 'topic' => $topic, + ]; + } +} diff --git a/app/Services/Payments/PaymentMissedCheckService.php b/app/Services/Payments/PaymentMissedCheckService.php new file mode 100644 index 00000000..1ae3e5b7 --- /dev/null +++ b/app/Services/Payments/PaymentMissedCheckService.php @@ -0,0 +1,74 @@ +toDateString(); + + return DB::table('invoices as i') + ->join('users as u', 'u.id', '=', 'i.parent_id') + ->select( + 'u.id as user_id', + 'u.firstname', + 'u.lastname', + 'u.email', + DB::raw('MIN(i.due_date) as due_date'), + DB::raw('SUM(i.balance) as balance') + ) + ->whereNotNull('i.due_date') + ->where('i.due_date', '<', $today) + ->where('i.balance', '>', 0) + ->whereNotIn(DB::raw('LOWER(i.status)'), ['paid']) + ->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + } + + public function sendReminders(array $users): array + { + $sent = 0; + $failed = 0; + + foreach ($users as $user) { + $userId = (int) ($user['user_id'] ?? 0); + $email = (string) ($user['email'] ?? ''); + $name = trim((string) ($user['firstname'] ?? '') . ' ' . (string) ($user['lastname'] ?? '')); + + $this->notifier->notifyUser( + $userId, + 'Payment Missed', + 'You have a missed payment. Please pay to avoid penalty.', + ['in_app'], + 'payment', + 'parent' + ); + + if ($email !== '') { + $subject = 'Payment Missed'; + $body = 'Dear ' . e($name !== '' ? $name : 'Parent') . ',
' + . 'You have a missed payment. Please pay to avoid penalty.
'; + $ok = $this->emailService->send($email, $subject, $body, 'finance'); + $ok ? $sent++ : $failed++; + } + } + + return [ + 'sent' => $sent, + 'failed' => $failed, + ]; + } +} diff --git a/app/Services/Payments/PaymentTestNotificationService.php b/app/Services/Payments/PaymentTestNotificationService.php new file mode 100644 index 00000000..a2765af5 --- /dev/null +++ b/app/Services/Payments/PaymentTestNotificationService.php @@ -0,0 +1,220 @@ + false, 'message' => 'Invalid email address.']; + } + + $type = in_array($type, ['no_payment', 'installment'], true) ? $type : 'no_payment'; + + $tzName = $this->resolveTimezone(); + $tz = new DateTimeZone($tzName); + $now = new DateTimeImmutable('now', $tz); + + $year = (int) $now->format('Y'); + $month = (int) $now->format('n'); + $schoolYear = (string) (Configuration::getConfig('school_year') ?? $year); + + $user = User::query()->where('email', $email)->first(); + $parentId = (int) ($user->id ?? 0); + + $invoices = $parentId > 0 + ? Invoice::query()->where('parent_id', $parentId)->where('school_year', $schoolYear)->get() + : collect(); + + $totalBalance = 0.0; + $latestInvoiceId = null; + foreach ($invoices as $invoice) { + $totalBalance += (float) ($invoice->balance ?? 0); + if ($latestInvoiceId === null) { + $latestInvoiceId = (int) ($invoice->id ?? 0); + } + } + + $ccEmail = $this->findSecondaryGuardianEmail($parentId, $email); + + $parentName = $user ? trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) : ''; + $monthYear = $now->format('F Y'); + $subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear); + $greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,'; + $balanceFmt = '$' . number_format($totalBalance, 2); + $intro = ($type === 'no_payment') + ? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet." + : "This is your monthly installment reminder for {$schoolYear}."; + + $installmentInfo = $this->computeInstallmentSuggestion($totalBalance, $tzName); + + $bodyHtml = << +{$greeting}
+{$intro}
+Current Outstanding Balance: {$balanceFmt}
++ As a friendly reminder, our tuition installments are due at the beginning of each month. + You can review your invoice and payment options by logging in to your parent portal. +
+Thank you for your prompt attention.
+Reminder Period: {$monthYear}
+ + HTML; + + $bodyHtml .= "' . e($fallbackTitle) . '
'; + } + } +} diff --git a/app/Services/School/EnrollmentEventService.php b/app/Services/School/EnrollmentEventService.php new file mode 100644 index 00000000..b9fea62d --- /dev/null +++ b/app/Services/School/EnrollmentEventService.php @@ -0,0 +1,418 @@ +parentName($parentData); + $studentName = $this->studentNameData($students, $parentData); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $studentName, + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Admission Application Under Review', + ]; + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Admission Under Review', + 'Your admission application is under review. We will notify you once it is approved.', + 'registration' + ); + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Admission Application Under Review', + $this->renderEmail('emails/status_admission_review', $emailData, 'Admission Application Under Review'), + 'registration' + ); + } + + public function paymentPending(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentName = $this->studentNameSentence($students, $parentData['student_name'] ?? 'your student'); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $studentName, + 'amountDue' => $parentData['amount'] ?? '', + 'dueDate' => $parentData['due_date'] ?? '', + 'portalLink' => $parentData['paymentLink'] ?? ($parentData['portalLink'] ?? url('/login')), + 'title' => 'Admission Decision', + ]; + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Payment Pending', + 'Your payment is pending. Please check your email for details.', + 'payment' + ); + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Admission Decision', + $this->renderEmail('emails/status_payment_pending', $emailData, 'Admission Decision'), + 'payment' + ); + } + + public function studentEnrolled(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentsDetails = $this->studentDetails($students); + $studentNameData = $this->studentNameData($students, $parentData); + $first = $studentsDetails[0] ?? []; + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $parentData['student_name'] ?? $studentNameData, + 'schoolYear' => $parentData['school_year'] ?? date('Y'), + 'studentId' => $parentData['student_id'] ?? ($first['studentId'] ?? ''), + 'gradeLevel' => $parentData['grade_level'] ?? ($first['gradeLevel'] ?? ''), + 'teacherName' => $parentData['teacher_name'] ?? ($first['teacherName'] ?? ''), + 'startDate' => $parentData['start_date'] ?? ($first['startDate'] ?? ''), + 'students' => count($studentsDetails) > 1 ? $studentsDetails : null, + 'portalLink' => url('/login'), + 'title' => 'Enrollment Confirmation', + ]; + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Enrollment Confirmed', + 'Enrollment confirmed. Please check your email for details.', + 'registration' + ); + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Enrollment Confirmation', + $this->renderEmail('emails/status_enrolled', $emailData, 'Enrollment Confirmation'), + 'registration' + ); + } + + public function withdrawUnderReview(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentName = $this->studentNameData($students, $parentData); + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Withdrawal Request Received', + 'Your withdrawal request is under review. Please check your email for details.', + 'registration' + ); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $parentData['student_name'] ?? $studentName, + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Withdrawal Under Review', + ]; + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Withdrawal Request Under Review', + $this->renderEmail('emails/status_withdraw_review', $emailData, 'Withdrawal Request Under Review'), + 'registration' + ); + } + + public function refundPending(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentName = $this->studentNameData($students, $parentData); + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Refund Pending', + 'A refund is being processed. Please check your email for details.', + 'payment' + ); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $studentName, + 'refundAmount' => $parentData['amount'] ?? '', + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Refund Pending', + ]; + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Refund Pending', + $this->renderEmail('emails/status_refund_pending', $emailData, 'Refund Pending'), + 'payment' + ); + } + + public function withdrawn(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentName = $this->studentNameData($students, $parentData); + $withdrawals = $this->withdrawalsList($students); + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Withdrawal Confirmed', + 'Your withdrawal has been completed. Please check your email for details.', + 'registration' + ); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $studentName, + 'withdrawalDate' => $parentData['withdrawal_date'] ?? ($parentData['date'] ?? ''), + 'withdrawals' => !empty($withdrawals) ? $withdrawals : null, + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Withdrawal Confirmed', + ]; + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Withdrawal Confirmed', + $this->renderEmail('emails/status_withdrawn', $emailData, 'Withdrawal Confirmed'), + 'registration' + ); + } + + public function denied(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentName = $this->studentNameData($students, $parentData); + $studentsWithReasons = $this->studentsWithReasons($students); + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Admission Decision', + 'We\'ve emailed you an update regarding your admission decision.', + 'registration' + ); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $studentName, + 'students' => $studentsWithReasons ?: null, + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Admission Decision', + ]; + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Admission Decision', + $this->renderEmail('emails/status_denied', $emailData, 'Admission Decision'), + 'registration' + ); + } + + public function waitlist(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + $studentName = $this->studentNameData($students, $parentData); + + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Waitlist Update', + 'Your application is currently on the waitlist. Please check your email for details.', + 'registration' + ); + + $emailData = [ + 'parentName' => $parentName, + 'studentName' => $studentName, + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Waitlist Update', + ]; + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Admission Decision', + $this->renderEmail('emails/status_waitlist', $emailData, 'Admission Decision'), + 'registration' + ); + } + + public function enrollmentStatusChanged(array $parentData, array $students): array + { + $parentName = $this->parentName($parentData); + + $emailData = [ + 'parentName' => $parentName, + 'schoolYear' => $parentData['school_year'] ?? '', + 'changes' => $parentData['changes'] ?? [], + 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'title' => 'Enrollment Status Update', + ]; + + $summaryCount = count($emailData['changes']); + $this->notifyParent( + (int) ($parentData['user_id'] ?? 0), + 'Enrollment Status Updated', + "Enrollment status updated for {$summaryCount} student(s). Check your email for details.", + 'registration' + ); + + return $this->sendEmail( + (string) ($parentData['email'] ?? ''), + 'Enrollment Status Update', + $this->renderEmail('emails/status_enrollment_batch', $emailData, 'Enrollment Status Update'), + 'registration' + ); + } + + private function parentName(array $parentData): string + { + return trim((string) ($parentData['firstname'] ?? '') . ' ' . (string) ($parentData['lastname'] ?? '')); + } + + private function studentNameData(array $studentdata, array $parentData) + { + $names = []; + foreach ($studentdata as $s) { + $nm = !empty($s['name']) + ? trim((string) $s['name']) + : trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? '')); + if ($nm !== '') { + $names[] = $nm; + } + } + + if (empty($names)) { + return $parentData['student_name'] ?? 'your student'; + } + if (count($names) === 1) { + return $names[0]; + } + return $names; + } + + private function studentNameSentence(array $studentdata, string $fallback): string + { + $names = []; + foreach ($studentdata as $s) { + $nm = !empty($s['name']) + ? trim((string) $s['name']) + : trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? '')); + if ($nm !== '') { + $names[] = $nm; + } + } + + if (count($names) === 0) { + return $fallback; + } + if (count($names) === 1) { + return $names[0]; + } + if (count($names) === 2) { + return $names[0] . ' and ' . $names[1]; + } + $last = array_pop($names); + return implode(', ', $names) . ', and ' . $last; + } + + private function studentDetails(array $studentdata): array + { + $details = []; + foreach ($studentdata as $s) { + $nm = !empty($s['name']) + ? trim((string) $s['name']) + : trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? '')); + if ($nm === '') { + continue; + } + $details[] = [ + 'name' => $nm, + 'studentId' => $s['student_id'] ?? $s['id'] ?? '', + 'gradeLevel' => $s['grade_level'] ?? $s['grade'] ?? '', + 'teacherName' => $s['teacher_name'] ?? $s['teacher'] ?? '', + 'startDate' => $s['start_date'] ?? '', + ]; + } + return $details; + } + + private function withdrawalsList(array $studentdata): array + { + $withdrawals = []; + foreach ($studentdata as $s) { + $nm = !empty($s['name']) + ? trim((string) $s['name']) + : trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? '')); + if ($nm === '') { + continue; + } + $wd = $s['withdrawal_date'] ?? $s['date'] ?? null; + if ($wd) { + $withdrawals[] = [ + 'name' => $nm, + 'withdrawalDate' => $wd, + ]; + } + } + return $withdrawals; + } + + private function studentsWithReasons(array $studentdata): array + { + $rows = []; + foreach ($studentdata as $s) { + $nm = !empty($s['name']) + ? trim((string) $s['name']) + : trim((string) ($s['firstname'] ?? '') . ' ' . (string) ($s['lastname'] ?? '')); + if ($nm === '') { + continue; + } + $rows[] = [ + 'name' => $nm, + 'reason' => $s['reason'] ?? null, + ]; + } + return $rows; + } + + private function notifyParent(int $userId, string $title, string $message, string $topic): void + { + if ($userId <= 0) { + return; + } + $this->notifier->notifyUser($userId, $title, $message, ['in_app'], $topic, 'parent'); + } + + private function sendEmail(string $to, string $subject, string $html, string $fromKey): array + { + if ($to === '') { + Log::warning('Enrollment event: missing recipient email', ['subject' => $subject]); + return ['ok' => false, 'message' => 'Missing recipient email.']; + } + + $ok = $this->emailService->send($to, $subject, $html, $fromKey); + if (!$ok) { + Log::error('Enrollment event email failed', ['to' => $to, 'subject' => $subject]); + } + return ['ok' => $ok]; + } + + private function renderEmail(string $viewName, array $data, string $fallbackTitle): string + { + try { + return view($viewName, $data, ['saveData' => true]); + } catch (\Throwable $e) { + return '' . e($fallbackTitle) . '
'; + } + } +} diff --git a/app/Services/School/PaymentEventService.php b/app/Services/School/PaymentEventService.php new file mode 100644 index 00000000..9c461303 --- /dev/null +++ b/app/Services/School/PaymentEventService.php @@ -0,0 +1,223 @@ + '$' . number_format((float) $v, 2); + $fmtDateTz = static function (?string $s): string { + if (!$s) { + return ''; + } + $dt = new \DateTime($s, new \DateTimeZone('UTC')); + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $dt->setTimezone(new \DateTimeZone($tzName ?: 'UTC')); + return $dt->format('m-d-Y h:i A'); + }; + + $txid = $data['transaction_id'] + ?? $data['transactionId'] + ?? $data['payment_id'] + ?? $data['id'] + ?? ''; + + $methodRaw = $data['payment_method'] ?? ($data['method'] ?? ''); + $method = $methodRaw ? ucfirst((string) $methodRaw) : ''; + + $amountPaid = $fmtMoney($data['amount'] ?? 0); + $preBalance = $fmtMoney($data['pre_balance'] ?? 0); + $postBalance = $fmtMoney($data['post_balance'] ?? 0); + $invoiceTotal = $fmtMoney($data['invoice_total'] ?? 0); + + $invoiceDiscountRaw = isset($data['invoice_discount']) ? (float) $data['invoice_discount'] : 0.0; + $parentYearDiscountTotalRaw = isset($data['parent_year_discount_total']) ? (float) $data['parent_year_discount_total'] : 0.0; + + $emailData = [ + 'title' => 'Payment Receipt', + 'parentName' => $parentName, + 'schoolYear' => $data['school_year'] ?? '', + 'semester' => $data['semester'] ?? '', + 'invoiceId' => $data['invoice_id'] ?? null, + 'invoiceNumber' => $data['invoice_number'] ?? null, + 'transactionId' => $txid, + 'paymentDate' => $fmtDateTz($data['payment_date'] ?? null), + 'method' => $method, + 'paymentMethod' => $method, + 'amount' => $amountPaid, + 'invoiceTotal' => $invoiceTotal, + 'preBalance' => $preBalance, + 'postBalance' => $postBalance, + 'checkNumber' => $data['check_number'] ?? null, + 'installmentSeq' => (int) ($data['installment_seq'] ?? 1), + 'invoiceDiscount' => $invoiceDiscountRaw > 0 ? $fmtMoney($invoiceDiscountRaw) : null, + 'parentYearDiscountTotal' => $parentYearDiscountTotalRaw > 0 ? $fmtMoney($parentYearDiscountTotalRaw) : null, + 'portalLink' => url('/login'), + 'students' => $studentdata, + ]; + + $inAppTitle = 'Payment Received'; + $inAppMsg = sprintf( + 'We received %s for Invoice #%s (Installment #%d). New balance: %s. Check your email for details.', + $emailData['amount'], + (string) ($emailData['invoiceId'] ?? $emailData['invoiceNumber'] ?? ''), + (int) $emailData['installmentSeq'], + $emailData['postBalance'] + ); + $this->notifyUser((int) ($data['user_id'] ?? 0), $inAppTitle, $inAppMsg, 'payments'); + + $emailBody = $this->renderEmail('emails/payment_receipt', $emailData, 'Payment Receipt'); + $subject = sprintf( + 'Payment Receipt - Invoice #%s (%s) %s', + (string) ($emailData['invoiceId'] ?? $emailData['invoiceNumber'] ?? ''), + $emailData['amount'], + $txid ? "[TX: {$txid}]" : '' + ); + + return $this->sendEmail((string) ($data['email'] ?? ''), $subject, $emailBody, 'payments'); + } + + public function extraCharge(array $data, array $studentdata = []): array + { + $fmtMoney = static fn ($v) => '$' . number_format((float) $v, 2); + $fmtFromUtc = static function (?string $s, string $fmt = 'm-d-Y h:i A'): string { + if (!$s) { + return ''; + } + try { + $dt = new \DateTime($s, new \DateTimeZone('UTC')); + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $dt->setTimezone(new \DateTimeZone($tzName ?: 'UTC')); + return $dt->format($fmt); + } catch (\Throwable $e) { + Log::error('extraCharge date error: ' . $e->getMessage()); + return ''; + } + }; + + $fmtDueLocal = static function (?string $s): string { + if (!$s) { + return ''; + } + try { + if (strlen($s) <= 10) { + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $dt = \DateTime::createFromFormat('Y-m-d', $s, new \DateTimeZone($tzName ?: 'UTC')); + return $dt ? $dt->format('m-d-Y') : ''; + } + + if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $s)) { + $dt = new \DateTime($s); + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $dt->setTimezone(new \DateTimeZone($tzName ?: 'UTC')); + return $dt->format('m-d-Y h:i A'); + } + + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $dt = new \DateTime($s, new \DateTimeZone($tzName ?: 'UTC')); + return $dt->format('m-d-Y h:i A'); + } catch (\Throwable $e) { + Log::error('extraCharge due date error: ' . $e->getMessage()); + return ''; + } + }; + + $parentName = trim((string) ($data['firstname'] ?? '') . ' ' . (string) ($data['lastname'] ?? '')); + + $amountSigned = (float) ($data['amount_signed'] ?? 0); + $amountAbs = (float) ($data['amount_abs'] ?? abs($amountSigned)); + $preBalance = $fmtMoney($data['pre_balance'] ?? 0); + $postBalance = $fmtMoney($data['post_balance'] ?? 0); + $invoiceTotal = $fmtMoney($data['invoice_total'] ?? 0); + $createdAt = $fmtFromUtc($data['created_at'] ?? null); + $dueDate = $fmtDueLocal($data['due_date'] ?? null); + $typeLabel = (($data['charge_type'] ?? 'add') === 'add') ? 'Added charge' : 'Deducted amount'; + $amountFmt = $fmtMoney($amountSigned); + + $emailData = [ + 'title' => 'Account Charge Update', + 'parentName' => $parentName, + 'schoolYear' => $data['school_year'] ?? '', + 'semester' => $data['semester'] ?? '', + 'invoiceId' => $data['invoice_id'] ?? null, + 'invoiceNumber' => $data['invoice_number'] ?? null, + 'typeLabel' => $typeLabel, + 'chargeTitle' => $data['charge_title'] ?? '', + 'chargeDesc' => $data['charge_desc'] ?? '', + 'chargeType' => $data['charge_type'] ?? 'add', + 'amountSigned' => $amountFmt, + 'amountAbs' => $fmtMoney($amountAbs), + 'invoiceTotal' => $invoiceTotal, + 'preBalance' => $preBalance, + 'postBalance' => $postBalance, + 'createdAt' => $createdAt, + 'dueDate' => $dueDate !== '' ? $dueDate : null, + 'portalLink' => $data['portal_link'] ?? url('/login'), + 'invoiceLink' => $data['invoice_link'] ?? null, + 'students' => $studentdata, + ]; + + $inAppTitle = 'Account Updated'; + $inAppMsg = sprintf( + '%s: %s (%s). New balance: %s.', + $typeLabel, + $emailData['chargeTitle'], + $emailData['amountSigned'], + $emailData['postBalance'] + ); + $this->notifyUser((int) ($data['user_id'] ?? 0), $inAppTitle, $inAppMsg, 'billing'); + + $subject = sprintf( + 'Charge Update - Invoice #%s (%s)', + (string) ($emailData['invoiceId'] ?? $emailData['invoiceNumber'] ?? ''), + $emailData['amountSigned'] + ); + + $body = $this->renderEmail('emails/extra_charge_notice', $emailData, 'Account Charge Update'); + return $this->sendEmail((string) ($data['email'] ?? ''), $subject, $body, 'billing'); + } + + private function notifyUser(int $userId, string $title, string $message, string $topic): void + { + if ($userId <= 0) { + return; + } + $this->notifier->notifyUser($userId, $title, $message, ['in_app'], $topic, 'parent'); + } + + private function sendEmail(string $to, string $subject, string $html, string $fromKey): array + { + if ($to === '') { + Log::warning('Payment event missing recipient email', ['subject' => $subject]); + return ['ok' => false, 'message' => 'Missing recipient email.']; + } + + $ok = $this->emailService->send($to, $subject, $html, $fromKey); + if (!$ok) { + Log::error('Payment event email failed', ['to' => $to, 'subject' => $subject]); + } + return ['ok' => $ok]; + } + + private function renderEmail(string $viewName, array $data, string $fallbackTitle): string + { + try { + return view($viewName, $data, ['saveData' => true]); + } catch (\Throwable $e) { + return '' . e($fallbackTitle) . '
'; + } + } +} diff --git a/app/Services/Staff/StaffTimeOffLinkService.php b/app/Services/Staff/StaffTimeOffLinkService.php new file mode 100644 index 00000000..c1bd8719 --- /dev/null +++ b/app/Services/Staff/StaffTimeOffLinkService.php @@ -0,0 +1,110 @@ +secret = $secret ?: (string) env('JWT_SECRET', 'change-me-in-env'); + $this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL; + } + + public function createToken(array $payload): string + { + $now = time(); + $data = array_merge($payload, [ + 'iat' => $now, + 'exp' => $now + $this->ttl, + 'ctx' => self::TOKEN_CONTEXT, + ]); + + return $this->jwtEncode($data, $this->secret); + } + + public function parseToken(?string $token): ?array + { + if (!is_string($token) || $token === '') { + return null; + } + + $payload = $this->jwtDecode($token, $this->secret); + if (!is_array($payload)) { + return null; + } + + if (($payload['ctx'] ?? null) !== self::TOKEN_CONTEXT) { + return null; + } + + $exp = (int) ($payload['exp'] ?? 0); + if ($exp > 0 && $exp < time()) { + return null; + } + + return $payload; + } + + private function jwtEncode(array $payload, string $secret): string + { + $header = ['typ' => 'JWT', 'alg' => 'HS256']; + $segments = []; + $segments[] = $this->base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES)); + $segments[] = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)); + $signingInput = implode('.', $segments); + + $signature = hash_hmac('sha256', $signingInput, $secret, true); + $segments[] = $this->base64UrlEncode($signature); + + return implode('.', $segments); + } + + private function jwtDecode(string $token, string $secret): ?array + { + $parts = explode('.', $token); + if (count($parts) !== 3) { + return null; + } + + [$header64, $payload64, $signature64] = $parts; + $header = json_decode($this->base64UrlDecode($header64), true); + $payload = json_decode($this->base64UrlDecode($payload64), true); + $signature = $this->base64UrlDecode($signature64); + + if (!is_array($header) || !is_array($payload)) { + return null; + } + + $signingInput = $header64 . '.' . $payload64; + if (($header['alg'] ?? '') !== 'HS256') { + return null; + } + + $expected = hash_hmac('sha256', $signingInput, $secret, true); + if (!hash_equals($expected, $signature)) { + return null; + } + + return $payload; + } + + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $data): string + { + $padding = strlen($data) % 4; + if ($padding > 0) { + $data .= str_repeat('=', 4 - $padding); + } + return base64_decode(strtr($data, '-_', '+/')) ?: ''; + } +} diff --git a/app/Services/System/ConfigUpdateService.php b/app/Services/System/ConfigUpdateService.php new file mode 100644 index 00000000..163abd26 --- /dev/null +++ b/app/Services/System/ConfigUpdateService.php @@ -0,0 +1,115 @@ + 'taskUpdateDateAgeReference', + 'enable_attendance_on' => 'taskEnableAttendanceOn', + 'enable_attendance_off' => 'taskEnableAttendanceOff', + 'set_semester_spring' => 'taskSetSemesterSpring', + 'set_semester_fall' => 'taskSetSemesterFall', + ]; + + public function availableTasks(): array + { + return array_keys($this->tasks); + } + + public function runTask(string $task, bool $dry, bool $force, DateTimeZone $tz): array + { + if (!isset($this->tasks[$task])) { + return ['ok' => false, 'message' => 'Unknown task.']; + } + + $lockFile = storage_path('app/locks/config_update_' . $task . '.lock'); + if (!is_dir(dirname($lockFile))) { + @mkdir(dirname($lockFile), 0775, true); + } + + $fp = @fopen($lockFile, 'c+'); + if (!$fp) { + return ['ok' => false, 'message' => 'Unable to open lock file.']; + } + + if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) { + fclose($fp); + return ['ok' => false, 'message' => 'Task is already running.']; + } + + try { + $method = $this->tasks[$task]; + $ok = (bool) $this->{$method}($dry, $force, $tz); + return ['ok' => $ok, 'task' => $task]; + } catch (\Throwable $e) { + Log::error('ConfigUpdate task failed: ' . $e->getMessage()); + return ['ok' => false, 'message' => $e->getMessage()]; + } finally { + try { + flock($fp, LOCK_UN); + fclose($fp); + @unlink($lockFile); + } catch (\Throwable $e) { + } + } + } + + private function taskEnableAttendanceOn(bool $dry, bool $force, DateTimeZone $tz): bool + { + return $this->setConfig('enable_attendance', '1', $dry); + } + + private function taskEnableAttendanceOff(bool $dry, bool $force, DateTimeZone $tz): bool + { + return $this->setConfig('enable_attendance', '0', $dry); + } + + private function taskUpdateDateAgeReference(bool $dry, bool $force, DateTimeZone $tz): bool + { + $now = new DateTimeImmutable('now', $tz); + $isJune1 = $now->format('n-j') === '6-1'; + if (!$isJune1 && !$force) { + return true; + } + + $value = $now->format('Y') . '-12-31'; + return $dry ? true : Configuration::setConfigValueByKey('date_age_reference', $value); + } + + private function taskSetSemesterSpring(bool $dry, bool $force, DateTimeZone $tz): bool + { + $now = new DateTimeImmutable('now', $tz); + $isFeb1 = $now->format('n-j') === '2-1'; + if (!$isFeb1 && !$force) { + return true; + } + + return $dry ? true : Configuration::setConfigValueByKey('semester', 'Spring'); + } + + private function taskSetSemesterFall(bool $dry, bool $force, DateTimeZone $tz): bool + { + $now = new DateTimeImmutable('now', $tz); + $isJun1 = $now->format('n-j') === '6-1'; + if (!$isJun1 && !$force) { + return true; + } + + return $dry ? true : Configuration::setConfigValueByKey('semester', 'Fall'); + } + + private function setConfig(string $key, string $value, bool $dry): bool + { + if ($dry) { + return true; + } + + return Configuration::setConfigValueByKey($key, $value); + } +} diff --git a/app/Services/Teachers/TeacherAbsenceService.php b/app/Services/Teachers/TeacherAbsenceService.php index b7328f8a..438612ef 100644 --- a/app/Services/Teachers/TeacherAbsenceService.php +++ b/app/Services/Teachers/TeacherAbsenceService.php @@ -2,7 +2,7 @@ namespace App\Services\Teachers; -use App\Libraries\StaffTimeOffLinkService; +use App\Services\Staff\StaffTimeOffLinkService; use App\Models\StaffAttendance; use App\Models\TeacherClass; use App\Models\User; diff --git a/app/Services/Users/InactiveUserCleanupService.php b/app/Services/Users/InactiveUserCleanupService.php new file mode 100644 index 00000000..ccbbac8d --- /dev/null +++ b/app/Services/Users/InactiveUserCleanupService.php @@ -0,0 +1,61 @@ +subMinutes($minutes)->toDateTimeString(); + + $users = DB::table('users') + ->select('id') + ->where('status', 'Inactive') + ->where('created_at', '<', $cutoff) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + if (empty($users)) { + $orphans = $this->purgeOrphanedUserRoles(); + return [ + 'deleted_users' => 0, + 'deleted_parents' => 0, + 'deleted_roles' => $orphans, + ]; + } + + $userIds = array_map(static fn ($u) => (int) ($u['id'] ?? 0), $users); + $userIds = array_values(array_filter($userIds)); + + $deletedParents = 0; + DB::transaction(function () use ($userIds, &$deletedParents): void { + $deletedParents = ParentModel::query() + ->whereIn('firstparent_id', $userIds) + ->delete(); + + DB::table('user_roles')->whereIn('user_id', $userIds)->delete(); + DB::table('users')->whereIn('id', $userIds)->delete(); + }); + + $orphans = $this->purgeOrphanedUserRoles(); + + return [ + 'deleted_users' => count($userIds), + 'deleted_parents' => (int) $deletedParents, + 'deleted_roles' => $orphans, + ]; + } + + private function purgeOrphanedUserRoles(): int + { + return DB::table('user_roles') + ->whereNotIn('user_id', function ($q) { + $q->select('id')->from('users'); + }) + ->delete(); + } +} diff --git a/app/Services/Whatsapp/WhatsappInviteEmailService.php b/app/Services/Whatsapp/WhatsappInviteEmailService.php new file mode 100644 index 00000000..b1039e5a --- /dev/null +++ b/app/Services/Whatsapp/WhatsappInviteEmailService.php @@ -0,0 +1,260 @@ + false, 'message' => 'No recipients provided.']; + } + + $sections = (array) ($payload['sections'] ?? []); + $links = array_values(array_unique(array_filter((array) ($payload['links'] ?? [])))); + $parent = $payload['parent'] ?? null; + $schoolYear = $payload['schoolYear'] ?? null; + $semester = $payload['semester'] ?? null; + + $items = $this->buildItems($sections, $links); + if (empty($items)) { + Log::warning('WhatsappInvite: no invite items built.'); + return ['ok' => false, 'message' => 'No invite links found.']; + } + + $subject = count($items) > 1 + ? 'Your WhatsApp Group Links (Multiple Classes)' + : 'Your WhatsApp Group Link'; + + $viewData = [ + 'parent' => $parent, + 'items' => $items, + 'sections' => $sections, + 'links' => $links, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + 'title' => $subject, + 'teacherNames' => $payload['teachers'] ?? [], + ]; + + $bodyHtml = ''; + try { + $bodyHtml = view('emails/whatsapp_invite', $viewData, ['saveData' => true]); + } catch (\Throwable $e) { + $lines = ['Assalamu Alaikum,', '', 'Here are your WhatsApp link(s):']; + foreach ($items as $it) { + $lines[] = ' - ' . $it['class_section_name'] . ': ' . $it['invite_link']; + } + $lines[] = ''; + $lines[] = 'Jazakumu-llahu khayran.'; + $bodyHtml = nl2br(implode("\n", $lines)); + } + + $ok = $this->emailService->send($to, $subject, $bodyHtml, 'notifications', $cc); + + $singleSectionId = count($items) === 1 + ? ((int) ($items[0]['class_section_id'] ?? 0) ?: null) + : null; + $parentId = (int) ($parent['id'] ?? 0); + + if ($ok) { + WhatsappInviteLog::logSuccess($parentId, implode(', ', $to), $singleSectionId, null); + Log::info('Whatsapp invite sent.', ['to' => $to, 'cc' => $cc]); + } else { + WhatsappInviteLog::logFailure($parentId, implode(', ', $to), 'Email send failed', $singleSectionId, null); + Log::error('Whatsapp invite failed.', ['to' => $to, 'cc' => $cc]); + } + + return [ + 'ok' => $ok, + 'recipients' => count($to), + ]; + } + + private function buildItems(array $sections, array $links): array + { + $seen = []; + $items = []; + + foreach ($sections as $section) { + $sid = isset($section['class_section_id']) ? (int) $section['class_section_id'] : 0; + $name = trim((string) ($section['class_section_name'] ?? ($sid ? ('Section ' . $sid) : 'Class'))); + $url = trim((string) ($section['invite_link'] ?? '')); + if ($url === '') { + continue; + } + $key = $sid . '|' . $url; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $items[] = [ + 'class_section_id' => $sid, + 'class_section_name' => $name, + 'invite_link' => $url, + 'qr_src' => $this->generateQrImage($url), + ]; + } + + if (empty($items) && !empty($links)) { + foreach ($links as $i => $url) { + $url = trim((string) $url); + if ($url === '') { + continue; + } + $key = '0|' . $url; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $items[] = [ + 'class_section_id' => 0, + 'class_section_name' => 'Class Link ' . ($i + 1), + 'invite_link' => $url, + 'qr_src' => $this->generateQrImage($url), + ]; + } + } + + return $items; + } + + private function generateQrImage(string $inviteLink): string + { + if ($inviteLink === '') { + return $this->qrFallback($inviteLink); + } + + $dir = storage_path('app/qrcodes'); + if (!is_dir($dir)) { + if (!@mkdir($dir, 0775, true) && !is_dir($dir)) { + Log::error('QR: cannot create dir', ['dir' => $dir]); + return $this->qrFallback($inviteLink); + } + } + if (!is_writable($dir)) { + Log::error('QR: dir not writable', ['dir' => $dir]); + return $this->qrFallback($inviteLink); + } + + $filename = 'wa_' . md5($inviteLink) . '.png'; + $path = $dir . DIRECTORY_SEPARATOR . $filename; + + if (!is_file($path)) { + $v5BuilderClass = '\\Endroid\\QrCode\\Builder\\Builder'; + $v5WriterClass = '\\Endroid\\QrCode\\Writer\\PngWriter'; + $v5Encoding = '\\Endroid\\QrCode\\Encoding\\Encoding'; + + $writerClass = '\\Endroid\\QrCode\\Writer\\PngWriter'; + $qrClass = '\\Endroid\\QrCode\\QrCode'; + + $chlQRCode = '\\chillerlan\\QRCode\\QRCode'; + $chlQROptions = '\\chillerlan\\QRCode\\QROptions'; + $chlQRMatrix = '\\chillerlan\\QRCode\\Data\\QRMatrix'; + + $lastErr = null; + + if (class_exists($v5BuilderClass) && class_exists($v5WriterClass)) { + try { + $builder = $v5BuilderClass::create() + ->writer(new $v5WriterClass()) + ->data($inviteLink); + if (class_exists($v5Encoding)) { + $builder->encoding(new $v5Encoding('UTF-8')); + } + $result = $builder->size(500)->margin(2)->build(); + $result->saveToFile($path); + } catch (\Throwable $e) { + $lastErr = 'Endroid v5: ' . $e->getMessage(); + } + } + + if (!is_file($path) && class_exists($writerClass)) { + try { + if (class_exists($qrClass) && method_exists($qrClass, 'create')) { + $qr = $qrClass::create($inviteLink); + } else { + $qr = class_exists($qrClass) ? new $qrClass($inviteLink) : null; + } + if ($qr) { + if (method_exists($qr, 'setSize')) { + $qr->setSize(500); + } + if (method_exists($qr, 'setMargin')) { + $qr->setMargin(2); + } + $writer = new $writerClass(); + $result = $writer->write($qr); + if (method_exists($result, 'saveToFile')) { + $result->saveToFile($path); + } else { + $raw = method_exists($result, 'getString') ? $result->getString() : (string) $result; + file_put_contents($path, $raw); + } + } + } catch (\Throwable $e) { + $lastErr = 'Endroid v3/v4: ' . $e->getMessage(); + } + } + + if (!is_file($path) && class_exists($chlQRCode) && class_exists($chlQROptions)) { + try { + $optsArr = [ + 'version' => 5, + 'outputType' => constant($chlQRCode . '::OUTPUT_IMAGE_PNG'), + 'scale' => 6, + 'imageBase64' => false, + 'quietzoneSize' => 2, + ]; + if (class_exists($chlQRMatrix) && defined($chlQRMatrix . '::ECC_H')) { + $optsArr['eccLevel'] = constant($chlQRMatrix . '::ECC_H'); + } + + $opts = new $chlQROptions($optsArr); + $qrcode = new $chlQRCode($opts); + $png = $qrcode->render($inviteLink); + file_put_contents($path, $png); + } catch (\Throwable $e) { + $lastErr = 'chillerlan: ' . $e->getMessage(); + } + } + + if (!is_file($path) && $lastErr) { + Log::error('QR generation failed', ['error' => $lastErr]); + } + } + + if (is_file($path)) { + $data = @file_get_contents($path); + if ($data !== false) { + return 'data:image/png;base64,' . base64_encode($data); + } + } + + return $this->qrFallback($inviteLink); + } + + private function qrFallback(string $inviteLink): string + { + $link = rawurlencode($inviteLink); + return 'https://api.qrserver.com/v1/create-qr-code/?size=260x260&qzone=2&data=' . $link; + } +} diff --git a/app/old/Commands/AttendanceAutoPublishCommand.php b/app/old/Commands/AttendanceAutoPublishCommand.php new file mode 100644 index 00000000..9f9ae92a --- /dev/null +++ b/app/old/Commands/AttendanceAutoPublishCommand.php @@ -0,0 +1,46 @@ +format('Y-m-d H:i:s'); + + $cutoffDate = AutoPublishLib::secondSundayBackwardDate($now); + + $builder = $db->table('attendance_day'); + $builder->where('status', 'submitted') + ->groupStart() + ->where('auto_publish_at <=', $nowStr) + ->orGroupStart() + ->where('auto_publish_at IS NULL', null, false) + ->where('date <=', $cutoffDate) + ->groupEnd() + ->groupEnd(); + + $count = $builder->countAllResults(false); // keep the WHEREs + + if ($count > 0) { + $builder->update([ + 'status' => 'published', + 'published_by' => 0, // system + 'published_at' => $nowStr, + 'updated_at' => $nowStr, + ]); + } + + CLI::write("Auto-publish checked at {$nowStr} (TZ: {$zone->getName()}). Published rows: {$count}."); + } +} diff --git a/app/old/Commands/CheckMissedPayments.php b/app/old/Commands/CheckMissedPayments.php new file mode 100644 index 00000000..a1280b9c --- /dev/null +++ b/app/old/Commands/CheckMissedPayments.php @@ -0,0 +1,35 @@ +getUsersWithMissedPayments(); + + foreach ($missedUsers as $user) { + NotificationService::toUser( + $user['id'], + 'Payment Missed', + 'You have a missed payment. Please pay to avoid penalty.', + ['in_app', 'email', 'sms'] + ); + CLI::write("Reminder sent to {$user['email']}", 'yellow'); + } + + CLI::write("Finished checking missed payments.", 'green'); + } +} \ No newline at end of file diff --git a/app/old/Commands/CleanupExpiredNotifications.php b/app/old/Commands/CleanupExpiredNotifications.php new file mode 100644 index 00000000..0e2188d6 --- /dev/null +++ b/app/old/Commands/CleanupExpiredNotifications.php @@ -0,0 +1,38 @@ +where('expires_at IS NOT NULL') + ->where('expires_at < NOW()') + ->findAll(); + + if (empty($expired)) { + CLI::write("ℹ No expired notifications found to soft delete.", 'yellow'); + return; + } + + $count = 0; + foreach ($expired as $note) { + $model->delete($note['id']); // Soft delete + $count++; + } + + CLI::write("✅ Soft-deleted {$count} expired notifications.", 'green'); + } + +} diff --git a/app/old/Commands/CleanupPasswordResets.php b/app/old/Commands/CleanupPasswordResets.php new file mode 100644 index 00000000..c254a368 --- /dev/null +++ b/app/old/Commands/CleanupPasswordResets.php @@ -0,0 +1,32 @@ +subDays(30)->toDateTimeString(); + + $count = $model->where('requested_at <', $threshold)->delete(); + + CLI::write("Deleted $count old password reset request(s).", 'green'); + } +} + + +/* +Add this cron job to run every 24hrs +0 0 * * * /usr/bin/php /path/to/project/public/index.php cleanup:password-resets >> /path/to/project/writable/logs/cleanup.log 2>&1 + +*/ \ No newline at end of file diff --git a/app/old/Commands/ConfigUpdate.php b/app/old/Commands/ConfigUpdate.php new file mode 100644 index 00000000..deb55986 --- /dev/null +++ b/app/old/Commands/ConfigUpdate.php @@ -0,0 +1,232 @@ +]'; + protected $options = [ + 'task' => 'Task name (or pass as first positional arg)', + 't' => 'Short form of --task', + 'dry' => 'Dry run (no DB writes)', + 'force' => 'Ignore lock and run anyway', + 'tz' => 'Timezone (default: configured school timezone)', + ]; + + + /** @var ConfigurationModel */ + protected $configModel; + + /** Map of available task names -> handler methods. */ + protected array $tasks = [ + 'update_date_age_reference' => 'taskUpdateDateAgeReference', + 'enable_attendance_on' => 'taskEnableAttendanceOn', + 'enable_attendance_off' => 'taskEnableAttendanceOff', + 'set_semester_spring' => 'taskSetSemesterSpring', + 'set_semester_fall' => 'taskSetSemesterFall', + ]; + + + /** + * Set enable_attendance = 1 + */ + protected function taskEnableAttendanceOn(bool $dry, DateTimeZone $tz): bool + { + return $this->setConfig('enable_attendance', '1', $dry); + } + + /** + * Set enable_attendance = 0 + */ + protected function taskEnableAttendanceOff(bool $dry, DateTimeZone $tz): bool + { + return $this->setConfig('enable_attendance', '0', $dry); + } + + protected function taskUpdateDateAgeReference(bool $dry, DateTimeZone $tz): bool + { + $now = new DateTime('now', $tz); + $isJune1 = ($now->format('n') === '6' && $now->format('j') === '1'); + $forced = (CLI::getOption('force') !== null); + + if (!$isJune1 && !$forced) { + CLI::write( + "Today is {$now->format('Y-m-d')} (not June 1) — skipping. Use --force to override.", + 'yellow' + ); + return true; // no-op, not an error + } + + $year = (int) $now->format('Y'); + $value = sprintf('%04d-12-31', $year); + + CLI::write("Set date_age_reference = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray'); + if ($dry) return true; + + // Use your model method + $ok = (bool) $this->configModel->setConfigValueByKey('date_age_reference', $value); + + if ($ok) { + CLI::write("date_age_reference updated to {$value}", 'green'); + } else { + CLI::error("Failed to update date_age_reference"); + } + return $ok; + } + + protected function taskSetSemesterSpring(bool $dry, DateTimeZone $tz): bool + { + $now = new DateTime('now', $tz); + $isFeb1 = ($now->format('n') === '2' && $now->format('j') === '1'); + $forced = (CLI::getOption('force') !== null); + + if (!$isFeb1 && !$forced) { + CLI::write( + "Today is {$now->format('Y-m-d')} (not Feb 1) — skipping. Use --force to override.", + 'yellow' + ); + return true; // no-op is success + } + + CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray'); + if ($dry) return true; + + return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring'); + } + + protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool + { + $now = new DateTime('now', $tz); + $isJun1 = ($now->format('n') === '6' && $now->format('j') === '1'); + $forced = (CLI::getOption('force') !== null); + + if (!$isJun1 && !$forced) { + CLI::write( + "Today is {$now->format('Y-m-d')} (not Jun 1) — skipping. Use --force to override.", + 'yellow' + ); + return true; + } + + CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray'); + if ($dry) return true; + + return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall'); + } + + public function run(array $params) + { + $this->configModel = model(ConfigurationModel::class); + + $tz = $this->getOptionString('tz', $params) ?? ((string)(config('School')->attendance['timezone'] ?? 'UTC')); + $task = $this->getOptionString('task', $params, 't'); + + // Fallback: first positional arg (php spark config:update enable_attendance_on) + if (!$task && !empty($params) && strpos($params[0], '-') !== 0) { + $task = trim($params[0]); + } + + $dry = $this->hasFlag('dry', $params); + $force = $this->hasFlag('force', $params); + $dtz = new DateTimeZone($tz); + + if ($task === '' || !isset($this->tasks[$task])) { + CLI::error('Invalid or missing --task. Available: ' . implode(', ', array_keys($this->tasks))); + return; + } + + // Per-task lock + $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "ci4_config_update_{$task}.lock"; + $fp = @fopen($lockFile, 'c+'); + if (!$fp) { + CLI::error("Unable to open lock file: $lockFile"); + return; + } + if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) { + CLI::error("Task '$task' is already running (lock held). Use --force to override."); + fclose($fp); + return; + } + + try { + $method = $this->tasks[$task]; + CLI::write("Running task: {$task}" . ($dry ? ' [DRY RUN]' : ''), 'yellow'); + $ok = $this->{$method}($dry, $dtz); + if ($ok === true) { + CLI::write("Task '{$task}' finished successfully.", 'green'); + } else { + CLI::error("Task '{$task}' finished with warnings or no changes."); + } + } catch (\Throwable $e) { + CLI::error("Task '{$task}' failed: " . $e->getMessage()); + } finally { + try { + flock($fp, LOCK_UN); + fclose($fp); + @unlink($lockFile); + } catch (\Throwable $e) { + } + } + } + + /** Accepts --name=value, --name value, -s value, or scans $params. */ + private function getOptionString(string $name, array $params, ?string $short = null): ?string + { + $v = CLI::getOption($name); + if (is_string($v) && $v !== '') return trim($v); + if ($short) { + $v = CLI::getOption($short); + if (is_string($v) && $v !== '') return trim($v); + } + foreach ($params as $i => $p) { + if (strpos($p, "--{$name}=") === 0) return trim(substr($p, strlen($name) + 3)); + if ($short && strpos($p, "-{$short}=") === 0) return trim(substr($p, strlen($short) + 2)); + if ($p === "--{$name}" || ($short && $p === "-{$short}")) { + return $params[$i + 1] ?? null; + } + } + return null; + } + + /** True if flag present as --name or -s (no value needed). */ + private function hasFlag(string $name, array $params, ?string $short = null): bool + { + if (CLI::getOption($name) !== null) return true; + if ($short && CLI::getOption($short) !== null) return true; + foreach ($params as $p) { + if ($p === "--{$name}" || ($short && $p === "-{$short}")) return true; + } + return false; + } + + + /* ------------------------- Helpers ------------------------- */ + protected function setConfig(string $key, string $value, bool $dry): bool + { + // show current value + $current = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '{$greeting}
+{$intro}
+Current Outstanding Balance: {$balanceFmt}
++ As a friendly reminder, our tuition installments are due at the beginning of each month. + You can review your invoice and payment options by logging in to your parent portal. +
++ If you have any questions or need to arrange a different plan, please reply to this email. +
+Thank you for your prompt attention.
+Reminder Period: {$monthYear}
+ + HTML; + + $body = view('emails/_wrap_layout', [ + 'title' => 'Monthly Tuition Reminder', + 'body_html' => $bodyHtml, + ], ['saveData' => true]); + + return [$subject, $body]; + } + + private function getHeadOfFinanceUsers(): array + { + // Try the explicit role label used in the UI first + $heads = $this->userModel->getUsersByRole('head of department (finance)'); + if (!empty($heads)) return $heads; + // fallback to accountant role if needed + $acct = $this->userModel->getUsersByRole('accountant'); + return $acct ?: []; + } +} diff --git a/app/old/Commands/SendTestPaymentNotification.php b/app/old/Commands/SendTestPaymentNotification.php new file mode 100644 index 00000000..611aac97 --- /dev/null +++ b/app/old/Commands/SendTestPaymentNotification.php @@ -0,0 +1,150 @@ +attendance['timezone'] ?? 'UTC'); + $tz = new \DateTimeZone($tzName); + $now = new \DateTime('now', $tz); + $year = (int)$now->format('Y'); + $month= (int)$now->format('n'); + $schoolYear = (string) ($config->getConfig('school_year') ?? $year); + + $userRow = $user->where('email', $email)->first(); + $parentId = (int)($userRow['id'] ?? 0); + + $invRows = $parentId ? $invoice->getAllInvoicesByUserIds([$parentId], $schoolYear) : []; + $totalBalance = 0.0; $latestInvoiceId = null; + foreach ($invRows as $ir) { + $totalBalance += (float)($ir['balance'] ?? 0); + if ($latestInvoiceId === null) $latestInvoiceId = (int)($ir['id'] ?? 0); + } + + // Secondary guardian if available + $ccEmail = null; + if ($parentId) { + $row = $fam->where('user_id', $parentId)->first(); + if ($row && !empty($row['family_id'])) { + $others = $fam->where('family_id', (int)$row['family_id']) + ->where('user_id !=', $parentId) + ->where('receive_emails', 1)->findAll(); + foreach ($others as $g) { + $ccEmail = $user->select('email')->find((int)$g['user_id'])['email'] ?? null; + if ($ccEmail && filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) break; + $ccEmail = null; + } + } + } + + // Compose body (reuse layout) + $parentName = trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? '')); + $monthYear = $now->format('F Y'); + $subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear); + $greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,'; + $balanceFmt = '$' . number_format($totalBalance, 2); + $intro = ($type === 'no_payment') + ? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet." + : "This is your monthly installment reminder for {$schoolYear}."; + + $bodyHtml = << +{$greeting}
+{$intro}
+Current Outstanding Balance: {$balanceFmt}
++ As a friendly reminder, our tuition installments are due at the beginning of each month. + You can review your invoice and payment options by logging in to your parent portal. +
+Thank you for your prompt attention.
+Reminder Period: {$monthYear}
+ + HTML; + // Compute remaining and max installments similar to Manual Payment UI + $installmentEndRaw = (string)$config->getConfig('installment_date'); + $tz = new \DateTimeZone($tzName); + $today = new \DateTimeImmutable('today', $tz); + $end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; } + $remMonths = 0; + if ($end) { + $y = (int)$end->format('Y') - (int)$today->format('Y'); + $m = (int)$end->format('n') - (int)$today->format('n'); + $remMonths = $y * 12 + $m; + if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1; + if ($remMonths < 0) $remMonths = 0; + } + $remainingInst = max(1, $remMonths); + $maxInst = max(($remMonths ?: 0), ($totalBalance > 0 ? 2 : 0)); + $instDueFmt = '$' . number_format($remainingInst > 0 ? ($totalBalance / $remainingInst) : 0, 2); + + $bodyHtml .= "Test
', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('attendance_tracking', [ + 'id' => 1, + 'is_notified' => 1, + ]); + } +} diff --git a/tests/Unit/Services/Attendance/AttendanceDailySummaryServiceTest.php b/tests/Unit/Services/Attendance/AttendanceDailySummaryServiceTest.php new file mode 100644 index 00000000..18d07945 --- /dev/null +++ b/tests/Unit/Services/Attendance/AttendanceDailySummaryServiceTest.php @@ -0,0 +1,68 @@ +insert([ + 'id' => 5, + 'firstname' => 'Parent', + 'lastname' => 'One', + 'email' => 'parent@example.com', + 'status' => 'Active', + ]); + + DB::table('students')->insert([ + 'id' => 10, + 'school_id' => 1, + 'parent_id' => 5, + 'firstname' => 'Student', + 'lastname' => 'One', + 'dob' => '2015-01-01', + 'age' => 10, + 'gender' => 'Male', + 'is_active' => 1, + 'photo_consent' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2024-2025', + ]); + + DB::table('attendance_data')->insert([ + 'class_id' => 1, + 'class_section_id' => 1, + 'student_id' => 10, + 'school_id' => 'S1', + 'date' => '2025-02-10', + 'status' => 'absent', + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ]); + + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->once()->andReturn(true); + + $service = new AttendanceDailySummaryService($notifier, $email); + + $count = $service->sendAbsenteesSummary(); + + $this->assertSame(1, $count); + } +} diff --git a/tests/Unit/Services/Attendance/AttendancePolicyServiceTest.php b/tests/Unit/Services/Attendance/AttendancePolicyServiceTest.php index d525ecf0..78da45ac 100644 --- a/tests/Unit/Services/Attendance/AttendancePolicyServiceTest.php +++ b/tests/Unit/Services/Attendance/AttendancePolicyServiceTest.php @@ -39,4 +39,12 @@ class AttendancePolicyServiceTest extends TestCase $this->assertTrue($isAdmin); } + + public function test_can_manage_early_dismissals_for_teacher(): void + { + $service = new AttendancePolicyService(); + $allowed = $service->canManageEarlyDismissals(['Teacher']); + + $this->assertTrue($allowed); + } } diff --git a/tests/Unit/Services/Attendance/AttendanceQueryServiceTest.php b/tests/Unit/Services/Attendance/AttendanceQueryServiceTest.php index 06ccde48..1cbac45c 100644 --- a/tests/Unit/Services/Attendance/AttendanceQueryServiceTest.php +++ b/tests/Unit/Services/Attendance/AttendanceQueryServiceTest.php @@ -95,7 +95,8 @@ class AttendanceQueryServiceTest extends TestCase new \App\Models\AttendanceData(), new \App\Models\Student(), new \App\Services\Attendance\AttendanceRecordSyncService(new \App\Models\AttendanceRecord()) - ) + ), + new \App\Services\Attendance\AttendanceAutoPublishService() ), new \App\Services\Attendance\AttendanceRecordSyncService(new \App\Models\AttendanceRecord()) ), diff --git a/tests/Unit/Services/Attendance/AttendanceSummaryRebuildServiceTest.php b/tests/Unit/Services/Attendance/AttendanceSummaryRebuildServiceTest.php new file mode 100644 index 00000000..4b0dd0af --- /dev/null +++ b/tests/Unit/Services/Attendance/AttendanceSummaryRebuildServiceTest.php @@ -0,0 +1,68 @@ +insert([ + [ + 'class_id' => 1, + 'class_section_id' => 10, + 'student_id' => 1, + 'school_id' => 'S1', + 'date' => '2025-02-01', + 'status' => 'present', + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + [ + 'class_id' => 1, + 'class_section_id' => 10, + 'student_id' => 1, + 'school_id' => 'S1', + 'date' => '2025-02-02', + 'status' => 'absent', + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + [ + 'class_id' => 1, + 'class_section_id' => 11, + 'student_id' => 2, + 'school_id' => 'S1', + 'date' => '2025-02-02', + 'status' => 'late', + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + ]); + + $service = new AttendanceSummaryRebuildService(); + $result = $service->rebuild(); + + $this->assertSame(2, $result['inserted']); + $this->assertDatabaseHas('attendance_record', [ + 'student_id' => 1, + 'total_presence' => 1, + 'total_absence' => 1, + 'total_late' => 0, + 'total_attendance' => 2, + ]); + $this->assertDatabaseHas('attendance_record', [ + 'student_id' => 2, + 'total_presence' => 0, + 'total_absence' => 0, + 'total_late' => 1, + 'total_attendance' => 1, + ]); + } +} diff --git a/tests/Unit/Services/Attendance/TeacherAttendanceSubmissionServiceTest.php b/tests/Unit/Services/Attendance/TeacherAttendanceSubmissionServiceTest.php index fcf4f6db..3aa4ece3 100644 --- a/tests/Unit/Services/Attendance/TeacherAttendanceSubmissionServiceTest.php +++ b/tests/Unit/Services/Attendance/TeacherAttendanceSubmissionServiceTest.php @@ -39,7 +39,8 @@ class TeacherAttendanceSubmissionServiceTest extends TestCase new \App\Models\AttendanceData(), new \App\Models\Student(), new \App\Services\Attendance\AttendanceRecordSyncService(new \App\Models\AttendanceRecord()) - ) + ), + new \App\Services\Attendance\AttendanceAutoPublishService() ); $this->expectException(\RuntimeException::class); diff --git a/tests/Unit/Services/Auth/PasswordResetCleanupServiceTest.php b/tests/Unit/Services/Auth/PasswordResetCleanupServiceTest.php new file mode 100644 index 00000000..c9df6b83 --- /dev/null +++ b/tests/Unit/Services/Auth/PasswordResetCleanupServiceTest.php @@ -0,0 +1,27 @@ +insert([ + ['ip_address' => '127.0.0.1', 'requested_at' => now()->subDays(40)], + ['ip_address' => '127.0.0.1', 'requested_at' => now()->subDays(5)], + ]); + + $service = new PasswordResetCleanupService(); + $deleted = $service->cleanup(30); + + $this->assertSame(1, $deleted); + $this->assertDatabaseCount('password_reset_requests', 1); + } +} diff --git a/tests/Unit/Services/Grading/BelowSixtyEmailServiceTest.php b/tests/Unit/Services/Grading/BelowSixtyEmailServiceTest.php new file mode 100644 index 00000000..583dcab3 --- /dev/null +++ b/tests/Unit/Services/Grading/BelowSixtyEmailServiceTest.php @@ -0,0 +1,102 @@ +send([]); + + $this->assertFalse($result['ok']); + } + + public function test_send_delivers_to_guardians(): void + { + DB::table('families')->insert([ + 'id' => 1, + 'family_code' => 'FAM-1', + 'household_name' => 'Family', + 'is_active' => 1, + ]); + + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5551112222', + 'email' => 'parent@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('family_guardians')->insert([ + 'family_id' => 1, + 'user_id' => 10, + 'relation' => 'primary', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + ]); + + DB::table('students')->insert([ + 'id' => 22, + 'school_id' => 1, + 'parent_id' => 10, + 'firstname' => 'Student', + 'lastname' => 'One', + 'dob' => '2015-01-01', + 'age' => 10, + 'gender' => 'Male', + 'is_active' => 1, + 'photo_consent' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + ]); + + DB::table('family_students')->insert([ + 'family_id' => 1, + 'student_id' => 22, + 'is_primary_home' => 1, + ]); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->once()->andReturn(true); + + $service = new BelowSixtyEmailService($email); + $result = $service->send([ + 'student_id' => 22, + 'student_name' => 'Student', + 'class_section_name' => 'Class', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'scores' => ['semester_score' => 55], + 'comment' => 'Needs improvement', + 'html' => 'Report
', + ]); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['sent']); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationActiveServiceTest.php b/tests/Unit/Services/Notifications/NotificationActiveServiceTest.php new file mode 100644 index 00000000..c3ca62e0 --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationActiveServiceTest.php @@ -0,0 +1,47 @@ +insert([ + [ + 'id' => 1, + 'title' => 'Parent', + 'message' => 'Message', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'scheduled_at' => now()->subHour(), + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 2, + 'title' => 'Teacher', + 'message' => 'Message', + 'target_group' => 'teacher', + 'delivery_channels' => json_encode(['in_app']), + 'scheduled_at' => now()->subHour(), + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new NotificationActiveService(); + $result = $service->list('parent'); + + $this->assertCount(1, $result['notifications']); + $this->assertFalse($result['notifications'][0]['isExpired']); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationCleanupServiceTest.php b/tests/Unit/Services/Notifications/NotificationCleanupServiceTest.php new file mode 100644 index 00000000..82878abe --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationCleanupServiceTest.php @@ -0,0 +1,52 @@ +insert([ + [ + 'id' => 1, + 'title' => 'Old', + 'message' => 'Old', + 'target_group' => 'all', + 'delivery_channels' => json_encode(['in_app']), + 'priority' => 1, + 'status' => 'sent', + 'scheduled_at' => now()->subDays(2), + 'expires_at' => now()->subDay(), + 'school_year' => '2024-2025', + 'semester' => 'Spring', + ], + [ + 'id' => 2, + 'title' => 'Active', + 'message' => 'Active', + 'target_group' => 'all', + 'delivery_channels' => json_encode(['in_app']), + 'priority' => 1, + 'status' => 'sent', + 'scheduled_at' => now()->subDay(), + 'expires_at' => now()->addDay(), + 'school_year' => '2024-2025', + 'semester' => 'Spring', + ], + ]); + + $service = new NotificationCleanupService(); + $deleted = $service->cleanupExpired(); + + $this->assertSame(1, $deleted); + $this->assertSoftDeleted('notifications', ['id' => 1]); + $this->assertDatabaseHas('notifications', ['id' => 2]); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationDeletedServiceTest.php b/tests/Unit/Services/Notifications/NotificationDeletedServiceTest.php new file mode 100644 index 00000000..c54b3481 --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationDeletedServiceTest.php @@ -0,0 +1,33 @@ +insert([ + 'id' => 1, + 'title' => 'Deleted', + 'message' => 'Message', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'deleted_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new NotificationDeletedService(); + $result = $service->list(); + + $this->assertCount(1, $result['notifications']); + $this->assertSame(1, $result['notifications'][0]['id']); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationDispatchServiceTest.php b/tests/Unit/Services/Notifications/NotificationDispatchServiceTest.php new file mode 100644 index 00000000..b3dba708 --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationDispatchServiceTest.php @@ -0,0 +1,21 @@ +sendEmail('parent@example.com', 'Subject', 'Message'); + $service->sendSms('5551112222', 'Message'); + + Log::shouldHaveReceived('info')->twice(); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationManagementServiceTest.php b/tests/Unit/Services/Notifications/NotificationManagementServiceTest.php new file mode 100644 index 00000000..f3da6090 --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationManagementServiceTest.php @@ -0,0 +1,66 @@ +insert([ + 'id' => 1, + 'title' => 'Old', + 'message' => 'Message', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new NotificationManagementService(); + $result = $service->update(1, ['title' => 'New']); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('notifications', [ + 'id' => 1, + 'title' => 'New', + ]); + } + + public function test_update_returns_false_when_missing(): void + { + $service = new NotificationManagementService(); + $result = $service->update(999, ['title' => 'New']); + + $this->assertFalse($result['ok']); + } + + public function test_delete_and_restore_notification(): void + { + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Old', + 'message' => 'Message', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new NotificationManagementService(); + $this->assertTrue($service->delete(1)); + $this->assertSoftDeleted('notifications', ['id' => 1]); + + $this->assertTrue($service->restore(1)); + $this->assertDatabaseHas('notifications', [ + 'id' => 1, + 'deleted_at' => null, + ]); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationReadServiceTest.php b/tests/Unit/Services/Notifications/NotificationReadServiceTest.php new file mode 100644 index 00000000..5506cbaf --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationReadServiceTest.php @@ -0,0 +1,39 @@ +insert([ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 0, + ]); + + $service = new NotificationReadService(); + $result = $service->markRead(1, 1); + + $this->assertTrue($result); + $this->assertDatabaseHas('user_notifications', [ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 1, + ]); + } + + public function test_mark_read_returns_false_when_missing(): void + { + $service = new NotificationReadService(); + + $this->assertFalse($service->markRead(1, 99)); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationRecipientServiceTest.php b/tests/Unit/Services/Notifications/NotificationRecipientServiceTest.php new file mode 100644 index 00000000..338d4e2b --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationRecipientServiceTest.php @@ -0,0 +1,55 @@ +insert([ + 'id' => 1, + 'name' => 'parent', + 'slug' => 'parent', + 'is_active' => 1, + ]); + + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5551112222', + 'email' => 'parent@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('user_roles')->insert([ + 'user_id' => 10, + 'role_id' => 1, + ]); + + $service = new NotificationRecipientService(); + $rows = $service->getRecipients('parent'); + + $this->assertCount(1, $rows); + $this->assertSame('parent@example.com', $rows[0]['email']); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationSendServiceTest.php b/tests/Unit/Services/Notifications/NotificationSendServiceTest.php new file mode 100644 index 00000000..bff0f1fa --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationSendServiceTest.php @@ -0,0 +1,50 @@ +shouldReceive('getRecipients') + ->with('parent') + ->andReturn([ + ['id' => 10, 'email' => 'parent1@example.com', 'phone' => '5551112222'], + ['id' => 11, 'email' => 'parent2@example.com', 'phone' => null], + ]); + + $dispatcher = Mockery::mock(NotificationDispatchService::class); + $dispatcher->shouldReceive('sendEmail')->once(); + $dispatcher->shouldReceive('sendSms')->once(); + + $service = new NotificationSendService($recipients, $dispatcher); + + $result = $service->send([ + 'title' => 'Announcement', + 'message' => 'Hello parents', + 'target_group' => 'parent', + 'channels' => ['in_app', 'email', 'sms'], + ], 99); + + $this->assertTrue($result['ok']); + $this->assertSame(2, $result['recipient_count']); + $this->assertDatabaseHas('notifications', [ + 'title' => 'Announcement', + 'target_group' => 'parent', + ]); + $this->assertSame(1, Notification::query()->count()); + $this->assertSame(2, UserNotification::query()->count()); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationShowServiceTest.php b/tests/Unit/Services/Notifications/NotificationShowServiceTest.php new file mode 100644 index 00000000..38650730 --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationShowServiceTest.php @@ -0,0 +1,47 @@ +insert([ + 'id' => 1, + 'title' => 'Hello', + 'message' => 'Message', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'scheduled_at' => now()->subHour(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_notifications')->insert([ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 0, + ]); + + $service = new NotificationShowService(); + $row = $service->getForUser(1, 1); + + $this->assertNotNull($row); + $this->assertSame(1, $row['notification_id']); + } + + public function test_get_for_user_returns_null_when_missing(): void + { + $service = new NotificationShowService(); + $row = $service->getForUser(1, 99); + + $this->assertNull($row); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationTriggerServiceTest.php b/tests/Unit/Services/Notifications/NotificationTriggerServiceTest.php new file mode 100644 index 00000000..df3f24f9 --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationTriggerServiceTest.php @@ -0,0 +1,28 @@ + 'bar']); + + Event::assertDispatched('customNotification'); + } + + public function test_to_user_dispatches_event(): void + { + Event::fake(); + + NotificationTriggerService::toUser(5, 'Hello', 'Message'); + + Event::assertDispatched('customNotification'); + } +} diff --git a/tests/Unit/Services/Notifications/NotificationUserListServiceTest.php b/tests/Unit/Services/Notifications/NotificationUserListServiceTest.php new file mode 100644 index 00000000..209e788a --- /dev/null +++ b/tests/Unit/Services/Notifications/NotificationUserListServiceTest.php @@ -0,0 +1,60 @@ +insert([ + [ + 'id' => 1, + 'title' => 'Unread', + 'message' => 'Message 1', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'scheduled_at' => now()->subHour(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 2, + 'title' => 'Read', + 'message' => 'Message 2', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'scheduled_at' => now()->subHours(2), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + DB::table('user_notifications')->insert([ + [ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 0, + ], + [ + 'notification_id' => 2, + 'user_id' => 1, + 'is_read' => 1, + ], + ]); + + $service = new NotificationUserListService(); + $result = $service->listForUser(1, [ + 'read' => true, + 'per_page' => 10, + ]); + + $this->assertSame(1, $result['pagination']['total']); + } +} diff --git a/tests/Unit/Services/Notifications/UserNotificationDispatchServiceTest.php b/tests/Unit/Services/Notifications/UserNotificationDispatchServiceTest.php new file mode 100644 index 00000000..5b024cbb --- /dev/null +++ b/tests/Unit/Services/Notifications/UserNotificationDispatchServiceTest.php @@ -0,0 +1,58 @@ +insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5551112222', + 'email' => 'parent@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', + ]); + + $service = new UserNotificationDispatchService(); + $result = $service->notifyUser(1, 'Title', 'Message', ['in_app'], 'registration', 'parent'); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('notifications', [ + 'title' => 'Title', + 'target_group' => 'parent', + ]); + $this->assertDatabaseHas('user_notifications', [ + 'user_id' => 1, + 'is_read' => 0, + ]); + } + + public function test_notify_user_rejects_invalid_user(): void + { + $service = new UserNotificationDispatchService(); + $result = $service->notifyUser(0, 'Title', 'Message'); + + $this->assertFalse($result['ok']); + } +} diff --git a/tests/Unit/Services/Payments/PaymentMissedCheckServiceTest.php b/tests/Unit/Services/Payments/PaymentMissedCheckServiceTest.php new file mode 100644 index 00000000..8faa9d38 --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentMissedCheckServiceTest.php @@ -0,0 +1,77 @@ +insert([ + 'id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'Late', + 'email' => 'late@example.com', + 'status' => 'Active', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 1, + 'invoice_number' => 'INV-1', + 'total_amount' => 100, + 'balance' => 50, + 'paid_amount' => 50, + 'status' => 'Unpaid', + 'school_year' => '2024-2025', + 'semester' => 'Spring', + 'issue_date' => '2025-01-01', + 'due_date' => '2025-02-01', + ]); + + $service = new PaymentMissedCheckService( + Mockery::mock(UserNotificationDispatchService::class), + Mockery::mock(EmailService::class) + ); + + $users = $service->findUsersWithMissedPayments(); + + $this->assertCount(1, $users); + $this->assertSame(1, (int) $users[0]['user_id']); + } + + public function test_send_reminders_sends_email_and_notification(): void + { + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->once()->andReturn(true); + + $service = new PaymentMissedCheckService($notifier, $email); + + $result = $service->sendReminders([ + [ + 'user_id' => 5, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'parent@example.com', + ], + ]); + + $this->assertSame(1, $result['sent']); + $this->assertSame(0, $result['failed']); + } +} diff --git a/tests/Unit/Services/Payments/PaymentTestNotificationServiceTest.php b/tests/Unit/Services/Payments/PaymentTestNotificationServiceTest.php new file mode 100644 index 00000000..f4c57ccf --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentTestNotificationServiceTest.php @@ -0,0 +1,79 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2024-2025'], + ['config_key' => 'installment_date', 'config_value' => '2025-06-01'], + ]); + + DB::table('users')->insert([ + ['id' => 10, 'firstname' => 'Primary', 'lastname' => 'Parent', 'email' => 'primary@example.com', 'status' => 'Active'], + ['id' => 11, 'firstname' => 'Secondary', 'lastname' => 'Parent', 'email' => 'secondary@example.com', 'status' => 'Active'], + ]); + + DB::table('families')->insert([ + 'id' => 1, + 'family_code' => 'FAM-1', + 'is_active' => 1, + ]); + + DB::table('family_guardians')->insert([ + [ + 'family_id' => 1, + 'user_id' => 10, + 'relation' => 'guardian', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + ], + [ + 'family_id' => 1, + 'user_id' => 11, + 'relation' => 'guardian', + 'is_primary' => 0, + 'receive_emails' => 1, + 'receive_sms' => 0, + ], + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 200.00, + 'balance' => 150.00, + 'paid_amount' => 50.00, + 'status' => 'Partially Paid', + 'school_year' => '2024-2025', + 'semester' => 'Spring', + 'issue_date' => '2025-01-01', + ]); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->twice()->andReturn(true); + + $service = new PaymentTestNotificationService($email); + $result = $service->send('primary@example.com', 'no_payment'); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('payment_notification_logs', [ + 'parent_id' => 10, + 'type' => 'no_payment', + 'to_email' => 'primary@example.com', + ]); + } +} diff --git a/tests/Unit/Services/Payments/PaypalPaymentSyncServiceTest.php b/tests/Unit/Services/Payments/PaypalPaymentSyncServiceTest.php new file mode 100644 index 00000000..cb02138a --- /dev/null +++ b/tests/Unit/Services/Payments/PaypalPaymentSyncServiceTest.php @@ -0,0 +1,77 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2024-2025'], + ['config_key' => 'semester', 'config_value' => 'Spring'], + ]); + + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 123, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'parent@example.com', + 'status' => 'Active', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 1, + 'invoice_number' => 'INV-1', + 'total_amount' => 100, + 'balance' => 100, + 'paid_amount' => 0, + 'status' => 'Unpaid', + 'school_year' => '2024-2025', + 'semester' => 'Spring', + 'issue_date' => '2025-01-01', + ]); + + DB::table('paypal_payments')->insert([ + 'id' => 1, + 'parent_school_id' => 123, + 'transaction_id' => 'TXN-1', + 'status' => 'COMPLETED', + 'amount' => 50, + 'synced' => 0, + 'sync_attempts' => 0, + 'created_at' => '2025-01-15 10:00:00', + ]); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->once()->andReturn(true); + + $service = new PaypalPaymentSyncService($email); + $result = $service->sync(false, false); + + $this->assertSame(1, $result['processed']); + $this->assertDatabaseHas('paypal_payments', [ + 'id' => 1, + 'synced' => 1, + ]); + $this->assertDatabaseHas('payments', [ + 'invoice_id' => 1, + 'parent_id' => 1, + 'paid_amount' => 50, + ]); + $this->assertDatabaseHas('invoices', [ + 'id' => 1, + 'balance' => 50, + ]); + } +} diff --git a/tests/Unit/Services/School/AccountEventServiceTest.php b/tests/Unit/Services/School/AccountEventServiceTest.php new file mode 100644 index 00000000..0d4a6ab0 --- /dev/null +++ b/tests/Unit/Services/School/AccountEventServiceTest.php @@ -0,0 +1,102 @@ +insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5551112222', + 'email' => 'parent@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('students')->insert([ + 'id' => 5, + 'school_id' => 1, + 'parent_id' => 1, + 'firstname' => 'Student', + 'lastname' => 'One', + 'dob' => '2015-01-01', + 'age' => 10, + 'gender' => 'Male', + 'is_active' => 1, + 'photo_consent' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + ]); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->twice()->andReturn(true); + + $notifier = Mockery::mock(UserNotificationDispatchService::class); + + $service = new AccountEventService($email, $notifier); + $result = $service->newAccountAdded([ + 'id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'cellphone' => '5551112222', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('families', [ + 'family_code' => 'FAM-1', + ]); + $this->assertDatabaseHas('family_guardians', [ + 'user_id' => 1, + 'is_primary' => 1, + ]); + $this->assertDatabaseHas('family_students', [ + 'student_id' => 5, + ]); + } + + public function test_delete_unverified_user_handles_missing_admin_emails(): void + { + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->andReturn(true); + + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $service = new AccountEventService($email, $notifier); + $result = $service->deleteUnverifiedUser([ + 'id' => 2, + 'email' => 'inactive@example.com', + ]); + + $this->assertTrue($result['ok']); + } +} diff --git a/tests/Unit/Services/School/EnrollmentEventServiceTest.php b/tests/Unit/Services/School/EnrollmentEventServiceTest.php new file mode 100644 index 00000000..c81a97f0 --- /dev/null +++ b/tests/Unit/Services/School/EnrollmentEventServiceTest.php @@ -0,0 +1,49 @@ +shouldReceive('send')->once()->andReturn(true); + + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $service = new EnrollmentEventService($email, $notifier); + $result = $service->admissionUnderReview([ + 'user_id' => 1, + 'email' => 'parent@example.com', + 'firstname' => 'Parent', + 'lastname' => 'User', + ], [ + ['firstname' => 'Student', 'lastname' => 'One'], + ]); + + $this->assertTrue($result['ok']); + } + + public function test_admission_under_review_requires_email(): void + { + $email = Mockery::mock(EmailService::class); + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $service = new EnrollmentEventService($email, $notifier); + $result = $service->admissionUnderReview([ + 'user_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + ], []); + + $this->assertFalse($result['ok']); + } +} diff --git a/tests/Unit/Services/School/PaymentEventServiceTest.php b/tests/Unit/Services/School/PaymentEventServiceTest.php new file mode 100644 index 00000000..8830ef22 --- /dev/null +++ b/tests/Unit/Services/School/PaymentEventServiceTest.php @@ -0,0 +1,61 @@ +shouldReceive('send')->once()->andReturn(true); + + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $service = new PaymentEventService($email, $notifier); + $result = $service->paymentReceived([ + 'user_id' => 1, + 'email' => 'parent@example.com', + 'firstname' => 'Parent', + 'lastname' => 'User', + 'amount' => 25, + 'invoice_id' => 10, + 'payment_date' => now()->toDateTimeString(), + 'installment_seq' => 1, + 'pre_balance' => 50, + 'post_balance' => 25, + 'invoice_total' => 100, + ]); + + $this->assertTrue($result['ok']); + } + + public function test_extra_charge_requires_email(): void + { + $email = Mockery::mock(EmailService::class); + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $service = new PaymentEventService($email, $notifier); + $result = $service->extraCharge([ + 'user_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'amount_signed' => 10, + 'amount_abs' => 10, + 'pre_balance' => 0, + 'post_balance' => 10, + 'invoice_total' => 100, + 'charge_title' => 'Fee', + 'charge_type' => 'add', + ]); + + $this->assertFalse($result['ok']); + } +} diff --git a/tests/Unit/Services/Staff/StaffTimeOffLinkServiceTest.php b/tests/Unit/Services/Staff/StaffTimeOffLinkServiceTest.php new file mode 100644 index 00000000..99bcd4c1 --- /dev/null +++ b/tests/Unit/Services/Staff/StaffTimeOffLinkServiceTest.php @@ -0,0 +1,32 @@ +createToken(['uid' => 10, 'email' => 'staff@example.com']); + $payload = $service->parseToken($token); + + $this->assertNotNull($payload); + $this->assertSame(10, $payload['uid']); + $this->assertSame('staff@example.com', $payload['email']); + } + + public function test_token_invalid_on_expiry(): void + { + $service = new StaffTimeOffLinkService('secret', 1); + + $token = $service->createToken(['uid' => 10]); + sleep(2); + $payload = $service->parseToken($token); + + $this->assertNull($payload); + } +} diff --git a/tests/Unit/Services/System/ConfigUpdateServiceTest.php b/tests/Unit/Services/System/ConfigUpdateServiceTest.php new file mode 100644 index 00000000..04c7ddef --- /dev/null +++ b/tests/Unit/Services/System/ConfigUpdateServiceTest.php @@ -0,0 +1,33 @@ +runTask('unknown', false, false, new DateTimeZone('UTC')); + + $this->assertFalse($result['ok']); + } + + public function test_run_task_sets_config_when_forced(): void + { + $service = new ConfigUpdateService(); + $result = $service->runTask('enable_attendance_on', false, true, new DateTimeZone('UTC')); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('configuration', [ + 'config_key' => 'enable_attendance', + 'config_value' => '1', + ]); + } +} diff --git a/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php b/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php index 76002de0..c626aea5 100644 --- a/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php +++ b/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Services\Teachers; -use App\Libraries\StaffTimeOffLinkService; +use App\Services\Staff\StaffTimeOffLinkService; use App\Services\SemesterRangeService; use App\Services\Semesters\SemesterConfigService; use App\Services\Teachers\TeacherAbsenceService; diff --git a/tests/Unit/Services/Users/InactiveUserCleanupServiceTest.php b/tests/Unit/Services/Users/InactiveUserCleanupServiceTest.php new file mode 100644 index 00000000..82fd3d16 --- /dev/null +++ b/tests/Unit/Services/Users/InactiveUserCleanupServiceTest.php @@ -0,0 +1,51 @@ +insert([ + 'id' => 1, + 'firstname' => 'Inactive', + 'lastname' => 'User', + 'email' => 'inactive@example.com', + 'status' => 'Inactive', + 'created_at' => now()->subMinutes(30), + ]); + + DB::table('parents')->insert([ + 'id' => 1, + 'secondparent_firstname' => 'Second', + 'secondparent_lastname' => 'Parent', + 'secondparent_gender' => 'Female', + 'secondparent_email' => 'second@example.com', + 'secondparent_phone' => '1234567890', + 'firstparent_id' => 1, + 'secondparent_id' => 2, + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 1, + 'role_id' => 1, + ]); + + $service = new InactiveUserCleanupService(); + $result = $service->cleanup(15); + + $this->assertSame(1, $result['deleted_users']); + $this->assertDatabaseMissing('users', ['id' => 1]); + $this->assertDatabaseMissing('parents', ['firstparent_id' => 1]); + $this->assertDatabaseMissing('user_roles', ['user_id' => 1]); + } +} diff --git a/tests/Unit/Services/Whatsapp/WhatsappInviteEmailServiceTest.php b/tests/Unit/Services/Whatsapp/WhatsappInviteEmailServiceTest.php new file mode 100644 index 00000000..2360499b --- /dev/null +++ b/tests/Unit/Services/Whatsapp/WhatsappInviteEmailServiceTest.php @@ -0,0 +1,43 @@ +send([]); + + $this->assertFalse($result['ok']); + } + + public function test_send_dispatches_email(): void + { + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->once()->andReturn(true); + + $service = new WhatsappInviteEmailService($email); + $result = $service->send([ + 'to_emails' => ['parent@example.com'], + 'sections' => [ + [ + 'class_section_id' => 1, + 'class_section_name' => 'Class A', + 'invite_link' => 'https://chat.whatsapp.com/test', + ], + ], + ]); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['recipients']); + } +}