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 = << +

Monthly Tuition Reminder (Test)

+

{$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 .= ""; + + if (view()->exists('emails._wrap_layout')) { + $body = view('emails._wrap_layout', [ + 'title' => 'Monthly Tuition Reminder', + 'body_html' => $bodyHtml, + ], ['saveData' => true]); + } else { + $body = $bodyHtml; + } + + $ok = $this->emailService->send($email, $subject, (string) $body, 'finance'); + if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) { + $this->emailService->send($ccEmail, $subject, (string) $body, 'finance'); + } + + $existing = PaymentNotificationLog::query() + ->where('parent_id', $parentId) + ->where('period_year', $year) + ->where('period_month', $month) + ->where('type', $type) + ->first(); + + $payload = [ + 'parent_id' => $parentId, + 'invoice_id' => $latestInvoiceId, + 'school_year' => $schoolYear, + 'period_year' => $year, + 'period_month' => $month, + 'type' => $type, + 'to_email' => $email, + 'cc_email' => $ccEmail, + 'head_fa_notified' => 0, + 'subject' => $subject, + 'body' => (string) $body, + 'status' => $ok ? 'sent' : 'failed', + 'error_message' => $ok ? null : 'Email send failed (see logs)', + 'balance_snapshot' => $totalBalance, + 'sent_at' => $now->format('Y-m-d H:i:s'), + ]; + + if ($existing) { + $existing->fill($payload)->save(); + $logId = (int) $existing->id; + } else { + $log = PaymentNotificationLog::query()->create($payload); + $logId = (int) $log->id; + } + + return [ + 'ok' => $ok, + 'log_id' => $logId, + 'cc_email' => $ccEmail, + ]; + } + + private function computeInstallmentSuggestion(float $balance, string $tzName): array + { + $installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? ''); + $tz = new DateTimeZone($tzName); + $today = new DateTimeImmutable('today', $tz); + + $end = null; + if ($installmentEndRaw !== '') { + try { + $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; + } + } + + $remainingInstallments = max(1, $remMonths); + $maxInstallments = max($remMonths ?: 0, $balance > 0 ? 2 : 0); + $installmentDue = $remainingInstallments > 0 ? ($balance / $remainingInstallments) : 0; + + return [ + 'remaining_installments' => $remainingInstallments, + 'max_installments' => $maxInstallments, + 'installment_due' => round($installmentDue, 2), + 'installment_due_fmt' => '$' . number_format($installmentDue, 2), + ]; + } + + private function findSecondaryGuardianEmail(int $parentId, string $primaryEmail): ?string + { + if ($parentId <= 0) { + return null; + } + + $row = FamilyGuardian::query()->where('user_id', $parentId)->first(); + if (!$row || empty($row->family_id)) { + return null; + } + + $others = FamilyGuardian::query() + ->where('family_id', (int) $row->family_id) + ->where('user_id', '!=', $parentId) + ->where('receive_emails', 1) + ->get(); + + foreach ($others as $guardian) { + $email = (string) (User::query()->where('id', (int) $guardian->user_id)->value('email') ?? ''); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) && strcasecmp($email, $primaryEmail) !== 0) { + return $email; + } + } + + return null; + } + + private function resolveTimezone(): string + { + $schoolConfig = config('School'); + if (is_object($schoolConfig) && isset($schoolConfig->attendance['timezone'])) { + return (string) $schoolConfig->attendance['timezone']; + } + if (is_array($schoolConfig) && isset($schoolConfig['attendance']['timezone'])) { + return (string) $schoolConfig['attendance']['timezone']; + } + + return (string) (config('app.timezone') ?: 'UTC'); + } +} diff --git a/app/Services/Payments/PaypalPaymentSyncService.php b/app/Services/Payments/PaypalPaymentSyncService.php new file mode 100644 index 00000000..01b9bcbf --- /dev/null +++ b/app/Services/Payments/PaypalPaymentSyncService.php @@ -0,0 +1,173 @@ +where('status', 'COMPLETED') + ->where('synced', 0) + ->where('sync_attempts', '<', 3) + ->whereNotNull('transaction_id') + ->get(); + + $synced = 0; + $failed = []; + + foreach ($entries as $entry) { + if (!$reportOnly) { + $entry->sync_attempts = (int) $entry->sync_attempts + 1; + $entry->save(); + } + + $user = User::query()->where('school_id', $entry->parent_school_id)->first(); + if (!$user) { + $failed[] = (string) $entry->transaction_id; + Log::error('PayPal sync: no user for parent_school_id', ['parent_school_id' => $entry->parent_school_id]); + continue; + } + + $invoice = Invoice::query() + ->where('parent_id', $user->id) + ->where('school_year', $schoolYear) + ->latest('created_at') + ->first(); + + if (!$reportOnly && !$dryRun) { + if ($invoice) { + $ok = $this->applyPayment($invoice, $entry->amount, (string) $entry->transaction_id, $schoolYear, $semester, $entry->created_at); + if (!$ok) { + $failed[] = (string) $entry->transaction_id; + continue; + } + } else { + Payment::query()->create([ + 'parent_id' => $user->id, + 'invoice_id' => 0, + 'total_amount' => $entry->amount, + 'paid_amount' => $entry->amount, + 'balance' => 0.00, + 'number_of_installments' => 1, + 'transaction_id' => $entry->transaction_id, + 'payment_method' => 'PayPal', + 'payment_date' => optional($entry->created_at)->format('Y-m-d'), + 'school_year' => $schoolYear, + 'semester' => $semester, + 'status' => 'Completed', + 'updated_by' => null, + ]); + } + + $entry->synced = 1; + $entry->save(); + } + + $synced++; + } + + $this->sendReport($mode, $synced, $failed); + + return [ + 'mode' => $mode, + 'processed' => $synced, + 'failed' => $failed, + ]; + } + + private function applyPayment(Invoice $invoice, float $amount, string $transactionId, string $schoolYear, string $semester, $createdAt): bool + { + $newPaid = (float) $invoice->paid_amount + $amount; + $newBalance = (float) $invoice->balance - $amount; + + $invoice->paid_amount = $newPaid; + $invoice->balance = $newBalance; + if ($newBalance <= 0) { + $invoice->status = 'Paid'; + } + + if (!$invoice->save()) { + return false; + } + + Payment::query()->create([ + 'parent_id' => $invoice->parent_id, + 'invoice_id' => $invoice->id, + 'total_amount' => $invoice->total_amount, + 'paid_amount' => $amount, + 'balance' => $newBalance, + 'number_of_installments' => 1, + 'transaction_id' => $transactionId, + 'payment_method' => 'PayPal', + 'payment_date' => $createdAt ? date('Y-m-d', strtotime((string) $createdAt)) : date('Y-m-d'), + 'status' => $newBalance <= 0 ? 'Full' : 'Partial', + 'check_file' => null, + 'updated_by' => null, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]); + + $this->updateEnrollmentStatusIfPaid($invoice->id, $schoolYear); + + return true; + } + + private function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): void + { + $invoice = Invoice::query()->find($invoiceId); + if (!$invoice || (float) $invoice->balance > 0) { + return; + } + + $students = Student::query() + ->where('parent_id', $invoice->parent_id) + ->where('school_year', $schoolYear) + ->get(); + + foreach ($students as $student) { + Enrollment::query() + ->where('student_id', $student->id) + ->update(['enrollment_status' => 'enrolled']); + } + } + + private function sendReport(string $mode, int $processed, array $failed): void + { + if ($processed === 0 && empty($failed)) { + Log::info("[{$mode}] No PayPal sync updates."); + return; + } + + $subject = "[{$mode}] PayPal Sync Report - " . now()->format('Y-m-d H:i'); + $body = "PayPal Sync Mode: {$mode}\n\n"; + $body .= "{$processed} PayPal payments processed.\n\n"; + + if (!empty($failed)) { + $body .= count($failed) . " failed transactions:\n" . implode("\n", $failed); + } else { + $body .= "No failed transactions.\n"; + } + + $this->emailService->send('support@alrahmaisgl.org', $subject, nl2br($body), 'finance'); + } +} diff --git a/app/Services/School/AccountEventService.php b/app/Services/School/AccountEventService.php new file mode 100644 index 00000000..e7386c50 --- /dev/null +++ b/app/Services/School/AccountEventService.php @@ -0,0 +1,324 @@ +renderEmail('emails/welcome_user', ['user' => $userData], $subject); + + $sent = false; + if (!empty($userData['email'])) { + $sent = $this->emailService->send( + (string) $userData['email'], + $subject, + $message, + 'registration' + ); + } + + $supportMessage = $this->renderEmail('emails/support_new_account', ['user' => $userData], $subject); + $this->emailService->send( + 'support@alrahmaisgl.org', + 'New Account Created: ' . trim((string) ($userData['firstname'] ?? '') . ' ' . (string) ($userData['lastname'] ?? '')), + $supportMessage, + 'notifications' + ); + + $this->ensureFamilyLinks($userData); + + return ['ok' => $sent]; + } + + public function studentRegistered(array $data): array + { + if (empty($data['parents']) || !is_array($data['parents'])) { + Log::warning('StudentRegistered: missing parent data'); + return ['ok' => false, 'message' => 'Missing parent data.']; + } + + $studentFullName = trim((string) ($data['firstname'] ?? '') . ' ' . (string) ($data['lastname'] ?? '')); + if (!empty($data['parents']['user_id'])) { + $title = 'Your child has been registered'; + $msg = "{$studentFullName} has been successfully registered to Al Rahma Sunday School."; + $this->notifier->notifyUser((int) $data['parents']['user_id'], $title, $msg, ['in_app', 'email'], 'registration', 'parent'); + } + + $adminMessage = $this->renderEmail('emails/admin_student_registered', ['student' => $data], 'Student Registered'); + $this->emailService->send( + 'registration@alrahmaisgl.org', + "New Student Registered: {$studentFullName}", + $adminMessage, + 'notifications' + ); + + return ['ok' => true]; + } + + public function deleteUnverifiedUser(array $user): array + { + $this->notifier->notifyUser( + (int) ($user['id'] ?? 0), + 'Account Deleted - Unverified', + 'Your account was deleted because it was not verified within the time limit.', + ['email'], + 'notifications', + 'parent' + ); + + $adminEmails = array_map('trim', explode(',', (string) Configuration::getConfigValueByKey('delete_email_notify'))); + $adminEmails = array_filter($adminEmails, static fn ($e) => $e !== ''); + foreach ($adminEmails as $adminEmail) { + $body = "Unverified account deleted: ID {$user['id']}, Email {$user['email']}"; + $this->emailService->send($adminEmail, 'Unverified User Deleted', $body, 'notifications'); + } + + return ['ok' => true]; + } + + public function userRegistered(array $user): array + { + $this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Welcome!', 'Thanks for registering with our school system.', ['in_app', 'email'], 'notifications', 'parent'); + return ['ok' => true]; + } + + public function userProfileUpdated(array $user): array + { + $this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Profile Updated', 'Your profile information was successfully updated.', ['in_app'], 'notifications', 'parent'); + return ['ok' => true]; + } + + public function userDeactivated(array $user): array + { + $this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Account Deactivated', 'Your account has been deactivated by the administrator.', ['in_app', 'email'], 'notifications', 'parent'); + return ['ok' => true]; + } + + public function scoresPosted(array $data): array + { + foreach ((array) ($data['students'] ?? []) as $student) { + $this->notifier->notifyUser((int) ($student['id'] ?? 0), 'New Score Available', 'A new score has been posted.', ['in_app', 'email'], 'registration', 'student'); + } + return ['ok' => true]; + } + + public function finalScoreReleased(array $data): array + { + foreach ((array) ($data['students'] ?? []) as $student) { + $this->notifier->notifyUser((int) ($student['id'] ?? 0), 'Final Score Released', 'Your final score for the semester is now available.', ['in_app', 'email'], 'registration', 'student'); + } + + $this->preparePromotionQueue(); + + return ['ok' => true]; + } + + public function studentMarkedAbsent(array $record): array + { + $msg = (string) ($record['student_name'] ?? 'Student') . ' was absent on ' . (string) ($record['date'] ?? ''); + $this->notifier->notifyUser((int) ($record['parent_id'] ?? 0), 'Absence Notification', $msg, ['in_app', 'email'], 'registration', 'parent'); + return ['ok' => true]; + } + + public function studentMarkedLate(array $record): array + { + $msg = (string) ($record['student_name'] ?? 'Student') . ' was late on ' . (string) ($record['date'] ?? ''); + $this->notifier->notifyUser((int) ($record['parent_id'] ?? 0), 'Late Notification', $msg, ['in_app', 'email'], 'registration', 'parent'); + return ['ok' => true]; + } + + public function paymentMissed(array $user): array + { + $this->notifier->notifyUser((int) ($user['id'] ?? 0), 'Payment Missed', 'You have missed a scheduled payment. Please settle your balance.', ['in_app', 'email'], 'payment', 'parent'); + return ['ok' => true]; + } + + public function classScheduleUpdated(array $data): array + { + foreach ((array) ($data['students'] ?? []) as $student) { + $this->notifier->notifyUser((int) ($student['id'] ?? 0), 'Schedule Update', 'Your class schedule has been updated.', ['in_app'], 'notifications', 'student'); + } + return ['ok' => true]; + } + + public function newMessageReceived(array $data): array + { + $msg = 'You have a new message from ' . (string) ($data['sender_name'] ?? ''); + $this->notifier->notifyUser((int) ($data['recipient_id'] ?? 0), 'New Message Received', $msg, ['in_app'], 'notifications', 'parent'); + return ['ok' => true]; + } + + public function systemAnnouncementPosted(array $data): array + { + $targetGroup = (string) ($data['target_group'] ?? 'everyone'); + $users = (array) ($data['users'] ?? []); + if (empty($users) && $targetGroup !== '') { + $users = User::getUsersByRole($targetGroup); + } + + foreach ($users as $user) { + $this->notifier->notifyUser((int) ($user['id'] ?? 0), (string) ($data['title'] ?? ''), (string) ($data['message'] ?? ''), ['in_app'], 'notifications', $targetGroup); + } + return ['ok' => true]; + } + + public function customNotification(array $data): array + { + $this->notifier->notifyUser( + (int) ($data['user_id'] ?? 0), + (string) ($data['title'] ?? ''), + (string) ($data['message'] ?? ''), + (array) ($data['channels'] ?? ['in_app']), + 'notifications', + (string) ($data['target_group'] ?? 'everyone') + ); + + return ['ok' => true]; + } + + private function ensureFamilyLinks(array $userData): void + { + $userId = (int) ($userData['id'] ?? 0); + if ($userId <= 0) { + return; + } + + DB::transaction(function () use ($userData, $userId): void { + $familyCode = 'FAM-' . $userId; + $family = Family::query()->where('family_code', $familyCode)->first(); + if (!$family) { + $householdName = trim('Family of ' . ((string) ($userData['firstname'] ?? '') . ' ' . (string) ($userData['lastname'] ?? ''))); + $family = Family::query()->create([ + 'family_code' => $familyCode, + 'household_name' => $householdName, + 'address_line1' => $userData['address_street'] ?? null, + 'city' => $userData['city'] ?? null, + 'state' => $userData['state'] ?? null, + 'postal_code' => $userData['zip'] ?? null, + 'primary_phone' => $userData['cellphone'] ?? null, + 'is_active' => 1, + ]); + } + + FamilyGuardian::query()->firstOrCreate([ + 'family_id' => (int) $family->id, + 'user_id' => $userId, + ], [ + 'relation' => 'primary', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + ]); + + $studentIds = Student::query() + ->where('parent_id', $userId) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + + foreach ($studentIds as $studentId) { + FamilyStudent::query()->firstOrCreate([ + 'family_id' => (int) $family->id, + 'student_id' => $studentId, + ], [ + 'is_primary_home' => 1, + ]); + } + }); + } + + private function preparePromotionQueue(): void + { + try { + $currentYear = (string) Configuration::getConfigValueByKey('school_year'); + if (!preg_match('/^(\d{4})-(\d{4})$/', $currentYear, $m)) { + return; + } + $y1 = (int) $m[1]; + $y2 = (int) $m[2]; + $nextYear = ($y1 + 1) . '-' . ($y2 + 1); + + $rows = DB::table('students as s') + ->select('s.id as student_id', 'sc.class_section_id', 'fall.semester_score as fall_score', 'spring.semester_score as spring_score') + ->leftJoin('student_class as sc', function ($join) use ($currentYear) { + $join->on('sc.student_id', '=', 's.id') + ->where('sc.school_year', '=', $currentYear); + }) + ->leftJoin('semester_scores as fall', function ($join) use ($currentYear) { + $join->on('fall.student_id', '=', 's.id') + ->where('fall.semester', '=', 'fall') + ->where('fall.school_year', '=', $currentYear); + }) + ->leftJoin('semester_scores as spring', function ($join) use ($currentYear) { + $join->on('spring.student_id', '=', 's.id') + ->where('spring.semester', '=', 'spring') + ->where('spring.school_year', '=', $currentYear); + }) + ->get(); + + foreach ($rows as $row) { + $fall = isset($row->fall_score) ? (float) $row->fall_score : null; + $spring = isset($row->spring_score) ? (float) $row->spring_score : null; + if ($fall === null || $spring === null) { + continue; + } + $finalAvg = ($fall + $spring) / 2.0; + if ($finalAvg < 60.0) { + continue; + } + $fromSection = (int) ($row->class_section_id ?? 0); + if ($fromSection <= 0) { + continue; + } + $fromClassId = (int) (ClassSection::getClassId($fromSection) ?? 0); + if ($fromClassId <= 0) { + continue; + } + $toClassId = $fromClassId + 1; + + PromotionQueue::upsertQueue([ + 'student_id' => (int) $row->student_id, + 'from_class_section_id' => $fromSection, + 'to_class_id' => $toClassId, + 'to_class_section_id' => null, + 'school_year_from' => $currentYear, + 'school_year_to' => $nextYear, + 'status' => 'queued', + 'updated_by' => null, + ]); + } + } catch (\Throwable $e) { + Log::error('Promotion queue build failed: ' . $e->getMessage()); + } + } + + 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/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'] ?? ''; + CLI::write("{$key}: current={$current}", 'light_gray'); + + CLI::write("Set {$key} = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray'); + if ($dry) return true; + + // ✅ use your model function + $ok = (bool) $this->configModel->setConfigValueByKey($key, $value); + + // read-back to verify what’s persisted + $after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? ''; + CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red'); + + return $ok && ($after === $value); + } +} diff --git a/app/old/Commands/DeleteInactiveUsers.php b/app/old/Commands/DeleteInactiveUsers.php new file mode 100644 index 00000000..b22812c9 --- /dev/null +++ b/app/old/Commands/DeleteInactiveUsers.php @@ -0,0 +1,105 @@ +subMinutes(15)->toDateTimeString(); + CLI::write("Cutoff time for deletion: $cutoffTime", 'blue'); + log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime); + + // ───────────────────────────────────────────────────── + // 1 Fetch inactive users older than 15 min + // ───────────────────────────────────────────────────── + $users = $db->table('users') + ->select('id, firstname, lastname, email, created_at') + ->where('status', 'Inactive') + ->where('created_at <', $cutoffTime) + ->get() + ->getResultArray(); + + if (empty($users)) { + CLI::write('No inactive users found to delete.', 'yellow'); + $this->purgeOrphanedUserRoles($db); + return; + } + + CLI::write('Found ' . count($users) . ' users for deletion.', 'green'); + + // collect IDs + $userIds = array_column($users, 'id'); + + // ───────────────────────────────────────────────────── + // 2 Transaction: delete parents → user_roles → users + // ───────────────────────────────────────────────────── + $db->transStart(); + + // 2-a Delete any secondary-parent rows tied to these users + $parentsBuilder = $db->table('parents'); + $deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete(); + CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue'); + log_message('info', "Deleted $deletedParents rows from parents table."); + + // 2-b Delete user_roles rows + $db->table('user_roles')->whereIn('user_id', $userIds)->delete(); + CLI::write('Deleted related user_roles rows.', 'blue'); + + // 2-c Delete users + $db->table('users')->whereIn('id', $userIds)->delete(); + CLI::write('Deleted users: ' . implode(', ', $userIds), 'green'); + + $db->transComplete(); + + if (!$db->transStatus()) { + CLI::write('Transaction failed; rolled back.', 'red'); + return; + } + + // Purge any stray user_role rows left behind (defensive) + $this->purgeOrphanedUserRoles($db); + + $msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows."; + CLI::write($msg, 'green'); + log_message('info', $msg); + } catch (\Throwable $e) { + CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red'); + log_message('error', 'Error deleting inactive users: ' . $e->getMessage()); + } + } + + /** + * Remove user_roles rows that point to non-existent users. + */ + private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void + { + $orphaned = $db->table('user_roles') + ->whereNotIn('user_id', function ($q) use ($db) { + $q->select('id')->from('users'); + }) + ->delete(); + + if ($orphaned) { + CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green'); + log_message('info', "Deleted $orphaned orphaned user_roles rows."); + } else { + CLI::write('No orphaned user_roles rows found.', 'yellow'); + } + } + + +} \ No newline at end of file diff --git a/app/old/Commands/RecalculateAttendance.php b/app/old/Commands/RecalculateAttendance.php new file mode 100644 index 00000000..d4944b62 --- /dev/null +++ b/app/old/Commands/RecalculateAttendance.php @@ -0,0 +1,93 @@ +table('attendance_record')->truncate(); + CLI::write('Successfully truncated attendance_record table.', 'green'); + } catch (\Throwable $e) { + CLI::error('Failed to truncate attendance_record table: ' . $e->getMessage()); + return; + } + + // 2. Get all attendance data + $attendanceData = $db->table('attendance_data') + ->orderBy('student_id', 'ASC') + ->orderBy('school_year', 'ASC') + ->orderBy('semester', 'ASC') + ->get()->getResultArray(); + + if (empty($attendanceData)) { + CLI::write('No attendance data found to process.', 'yellow'); + return; + } + + $summary = []; + + // 3. Process the data + foreach ($attendanceData as $row) { + $studentId = $row['student_id']; + $schoolYear = $row['school_year']; + $semester = $row['semester']; + $status = strtolower($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'], + 'school_id' => $row['school_id'], + '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']++; + } + } + + // 4. Insert the new summary records + if (!empty($summary)) { + $builder = $db->table('attendance_record'); + try { + $builder->insertBatch(array_values($summary)); + CLI::write('Successfully inserted ' . count($summary) . ' summary records.', 'green'); + } catch (\Throwable $e) { + CLI::error('Failed to insert summary records: ' . $e->getMessage()); + return; + } + } + + CLI::write('Attendance summary recalculation finished.', 'green'); + } +} diff --git a/app/old/Commands/SendAbsenteesSummary.php b/app/old/Commands/SendAbsenteesSummary.php new file mode 100644 index 00000000..361c5efd --- /dev/null +++ b/app/old/Commands/SendAbsenteesSummary.php @@ -0,0 +1,42 @@ +getTodayAbsentees(); + + foreach ($absentRecords as $record) { + // Get parent user ID + $parent = $userModel->find($record['parent_id']); + if (!$parent) continue; + + NotificationService::toUser( + $parent['id'], + 'Daily Attendance Update', + "{$record['student_name']} was marked absent on {$record['date']}.", + ['in_app', 'email'] + ); + + CLI::write("Sent summary to parent: {$parent['email']}", 'yellow'); + } + + CLI::write("Attendance summary completed.", 'green'); + } +} \ No newline at end of file diff --git a/app/old/Commands/SendLatesSummary.php b/app/old/Commands/SendLatesSummary.php new file mode 100644 index 00000000..c37487a6 --- /dev/null +++ b/app/old/Commands/SendLatesSummary.php @@ -0,0 +1,42 @@ +getTodayLates(); + + foreach ($lateRecords as $record) { + // Get parent user ID + $parent = $userModel->find($record['parent_id']); + if (!$parent) continue; + + NotificationService::toUser( + $parent['id'], + 'Daily Attendance Update', + "{$record['student_name']} was marked late on {$record['date']}.", + ['in_app', 'email'] + ); + + CLI::write("Sent summary to parent: {$parent['email']}", 'yellow'); + } + + CLI::write("Attendance summary completed.", 'green'); + } +} diff --git a/app/old/Commands/SendMonthlyPaymentNotifications.php b/app/old/Commands/SendMonthlyPaymentNotifications.php new file mode 100644 index 00000000..bc6616f3 --- /dev/null +++ b/app/old/Commands/SendMonthlyPaymentNotifications.php @@ -0,0 +1,372 @@ +emailService = new EmailService(); + $this->configModel = new ConfigurationModel(); + $this->invoiceModel = new InvoiceModel(); + $this->paymentModel = new PaymentModel(); + $this->logModel = new PaymentNotificationLogModel(); + $this->userModel = new UserModel(); + $this->familyGuardianModel = new FamilyGuardianModel(); + + // Parse params: --force, --email=addr, --type=no_payment|installment + $force = false; + $targetEmail = null; + $targetType = null; + foreach ($params as $p) { + if ($p === '--force') { $force = true; continue; } + if (strpos($p, '--email=') === 0) { $targetEmail = trim(substr($p, 8)); continue; } + if (strpos($p, '--type=') === 0) { $targetType = trim(substr($p, 7)); continue; } + } + // Also support CI4 options parser + $optEmail = CLI::getOption('email'); + if ($optEmail !== null) $targetEmail = $optEmail; + $optType = CLI::getOption('type'); + if ($optType !== null) $targetType = $optType; + if (CLI::getOption('force') !== null) $force = true; + + $tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC'); + $tz = new \DateTimeZone($tzName); + $now = new \DateTime('now', $tz); + $year = (int) $now->format('Y'); + $month = (int) $now->format('n'); + + if (!$targetEmail && !$force && !$this->isFirstSaturday($now)) { + CLI::write('Not the first Saturday of the month. Use --force to override.', 'yellow'); + return; + } + + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? $year); + + // Targeted test mode: only send to a specific email + if ($targetEmail) { + if (!filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) { + CLI::write('Invalid --email provided', 'red'); + return; + } + + $userRow = $this->userModel->where('email', $targetEmail)->first(); + $parentId = (int)($userRow['id'] ?? 0); + + $invRows = $parentId ? $this->invoiceModel->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); + } + } + + $type = in_array($targetType, ['no_payment','installment'], true) ? $targetType : 'no_payment'; + $ccEmail = $parentId ? $this->getSecondaryGuardianEmail($parentId) : null; + + [$subject, $body] = $this->composeEmail($parentId ?: 0, $schoolYear, $type, $totalBalance, $now); + + $sentOk = $this->emailService->send($targetEmail, $subject, $body, 'finance'); + if ($sentOk && $ccEmail && strcasecmp($ccEmail, $targetEmail) !== 0) { + $this->emailService->send($ccEmail, $subject, $body, 'finance'); + } + + // Upsert log for this month + $existing = $this->logModel->where('parent_id', $parentId) + ->where('period_year', $year) + ->where('period_month', $month) + ->where('type', $type) + ->first(); + + $payload = [ + 'parent_id' => $parentId, + 'invoice_id' => $latestInvoiceId, + 'school_year' => $schoolYear, + 'period_year' => $year, + 'period_month' => $month, + 'type' => $type, + 'to_email' => $targetEmail, + 'cc_email' => $ccEmail, + 'head_fa_notified' => 0, + 'subject' => $subject, + 'body' => $body, + 'status' => $sentOk ? 'sent' : 'failed', + 'error_message' => $sentOk ? null : 'Email send failed (see logs)', + 'balance_snapshot' => $totalBalance, + 'sent_at' => $now->format('Y-m-d H:i:s'), + ]; + + if ($existing) { + $this->logModel->update($existing['id'], $payload); + } else { + $this->logModel->insert($payload); + } + + CLI::write(($sentOk ? 'Sent' : 'Failed') . " test reminder to {$targetEmail}", $sentOk ? 'green' : 'red'); + return; + } + + // Get all parents with invoices for the current school year + $db = \Config\Database::connect(); + $rows = $db->table('invoices') + ->select('parent_id') + ->where('school_year', $schoolYear) + ->groupBy('parent_id') + ->get() + ->getResultArray(); + + $parentIds = array_values(array_unique(array_map(static fn($r) => (int) $r['parent_id'], $rows))); + if (empty($parentIds)) { + CLI::write('No invoices found for current school year. Nothing to do.', 'yellow'); + return; + } + + $sentCount = 0; + foreach ($parentIds as $parentId) { + // Compute total balance across invoices for this year + $invRows = $this->invoiceModel->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); + } + } + + if ($totalBalance <= 0.0) { + continue; // up to date + } + + // Determine if parent has any payments this school year + $hasPayments = $db->table('payments') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->countAllResults() > 0; + + $type = $hasPayments ? 'installment' : 'no_payment'; + + // Idempotency guard: skip if already sent this period for this type + if ($this->logModel->existsForPeriod($parentId, $year, $month, $type)) { + continue; + } + + // Recipient emails: primary guardian (current parent), CC second guardian in same family + $toEmail = $this->getUserEmail($parentId); + $ccEmail = $this->getSecondaryGuardianEmail($parentId); + + if (!$toEmail) { + // Log failed attempt due to missing email and continue + $this->logModel->insert([ + 'parent_id' => $parentId, + 'invoice_id' => $latestInvoiceId, + 'school_year' => $schoolYear, + 'period_year' => $year, + 'period_month' => $month, + 'type' => $type, + 'to_email' => null, + 'cc_email' => $ccEmail, + 'subject' => 'Monthly Tuition Reminder', + 'body' => null, + 'status' => 'failed', + 'error_message' => 'No primary email on file', + 'balance_snapshot' => $totalBalance, + ]); + CLI::write("Skipped parent {$parentId} due to missing email", 'yellow'); + continue; + } + + // Compose email + [$subject, $body] = $this->composeEmail($parentId, $schoolYear, $type, $totalBalance, $now); + + $sentOk = $this->emailService->send($toEmail, $subject, $body, 'finance'); + if ($sentOk && $ccEmail && strcasecmp($ccEmail, $toEmail) !== 0) { + // Send a separate copy to the secondary guardian + $this->emailService->send($ccEmail, $subject, $body, 'finance'); + } + + // Notify Head of Finance (in-app) + $headFaUsers = $this->getHeadOfFinanceUsers(); + foreach ($headFaUsers as $u) { + NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent', + sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $totalBalance), + ['in_app'] + ); + } + + $this->logModel->insert([ + 'parent_id' => $parentId, + 'invoice_id' => $latestInvoiceId, + 'school_year' => $schoolYear, + 'period_year' => $year, + 'period_month' => $month, + 'type' => $type, + 'to_email' => $toEmail, + 'cc_email' => $ccEmail, + 'head_fa_notified' => !empty($headFaUsers) ? 1 : 0, + 'subject' => $subject, + 'body' => $body, + 'status' => $sentOk ? 'sent' : 'failed', + 'error_message' => $sentOk ? null : 'Email send failed (see logs)', + 'balance_snapshot' => $totalBalance, + 'sent_at' => $now->format('Y-m-d H:i:s'), + ]); + + if ($sentOk) { + $sentCount++; + CLI::write("Reminder sent to {$toEmail}" . ($ccEmail ? ", CC {$ccEmail}" : ''), 'green'); + } else { + CLI::write("Failed to send to {$toEmail}", 'red'); + } + } + + // Summary to head of finance (in-app broadcast) + $headFaUsers = $this->getHeadOfFinanceUsers(); + foreach ($headFaUsers as $u) { + NotificationService::toUser((int)$u['id'], 'Monthly Payment Reminders Summary', + sprintf('Total reminders sent this run: %d (School Year: %s).', $sentCount, $schoolYear), + ['in_app'] + ); + } + + CLI::write("Done. Total sent: {$sentCount}", 'green'); + } + + private function isFirstSaturday(\DateTimeInterface $dt): bool + { + // Saturday = 6 (PHP: 0 Sun .. 6 Sat) + $isSaturday = ((int)$dt->format('w')) === 6; + $isFirstWeek = ((int)$dt->format('j')) <= 7; + return $isSaturday && $isFirstWeek; + } + + private function getUserEmail(int $userId): ?string + { + $u = $this->userModel->select('email')->find($userId); + $email = $u['email'] ?? null; + return $email && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + } + + private function getSecondaryGuardianEmail(int $primaryGuardianUserId): ?string + { + // Find family of primary guardian + $row = $this->familyGuardianModel->where('user_id', $primaryGuardianUserId)->first(); + if (!$row || empty($row['family_id'])) { + return null; + } + $familyId = (int)$row['family_id']; + + $others = $this->familyGuardianModel + ->where('family_id', $familyId) + ->where('user_id !=', $primaryGuardianUserId) + ->where('receive_emails', 1) + ->findAll(); + + foreach ($others as $g) { + $email = $this->getUserEmail((int)$g['user_id']); + if ($email) return $email; + } + return null; + } + + private function composeEmail(int $parentId, string $schoolYear, string $type, float $balance, \DateTimeInterface $now): array + { + $parent = $this->userModel->find($parentId) ?: []; + $parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')); + $monthYear = $now->format('F Y'); + + $subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear); + $greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,'; + $balanceFmt = '$' . number_format($balance, 2); + + if ($type === 'no_payment') { + $intro = "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."; + } else { + $intro = "This is your monthly installment reminder for {$schoolYear}."; + } + + // Compute remaining and max installments similar to Manual Payment UI + $installmentEndRaw = (string)$this->configModel->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), ($balance > 0 ? 2 : 0)); + $instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2); + + $bodyHtml = << +

Monthly Tuition Reminder

+

{$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 = << +

Monthly Tuition Reminder (Test)

+

{$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 .= ""; + + $body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]); + + $ok = $mailer->send($email, $subject, $body, 'finance'); + if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) { + $mailer->send($ccEmail, $subject, $body, 'finance'); + } + + $existing = $logs->where('parent_id', $parentId) + ->where('period_year', $year) + ->where('period_month', $month) + ->where('type', $type) + ->first(); + + $payload = [ + 'parent_id' => $parentId, + 'invoice_id' => $latestInvoiceId, + 'school_year' => $schoolYear, + 'period_year' => $year, + 'period_month' => $month, + 'type' => $type, + 'to_email' => $email, + 'cc_email' => $ccEmail, + 'head_fa_notified' => 0, + 'subject' => $subject, + 'body' => $body, + 'status' => $ok ? 'sent' : 'failed', + 'error_message' => $ok ? null : 'Email send failed (see logs)', + 'balance_snapshot' => $totalBalance, + 'sent_at' => $now->format('Y-m-d H:i:s'), + ]; + if ($existing) $logs->update($existing['id'], $payload); else $logs->insert($payload); + + CLI::write(($ok ? 'Sent' : 'Failed') . " test reminder to {$email}", $ok ? 'green' : 'red'); + } +} diff --git a/app/old/Commands/SyncPaypalPayments.php b/app/old/Commands/SyncPaypalPayments.php new file mode 100644 index 00000000..05667ca8 --- /dev/null +++ b/app/old/Commands/SyncPaypalPayments.php @@ -0,0 +1,232 @@ +configModel = new ConfigurationModel(); + $this->paypalModel = new PayPalPaymentModel(); + $this->paymentModel = new PaymentModel(); + $this->userModel = new UserModel(); + $this->invoiceModel = new InvoiceModel(); + $this->studentModel = new StudentModel(); + $this->enrollmentModel = new EnrollmentModel(); + + $this->semester = $this->configModel->getConfig('semester'); + $this->schoolYear = $this->configModel->getConfig('school_year'); + } + + public function run(array $params) + { + $dryRun = CLI::getOption('dry-run'); + $reportOnly = CLI::getOption('report-only'); + $mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE'); + + $paypalEntries = $this->paypalModel + ->where('status', 'COMPLETED') + ->where('synced', 0) + ->where('sync_attempts <', 3) + ->where('transaction_id IS NOT NULL') + ->findAll(); + + $syncedCount = 0; + $failed = []; + + foreach ($paypalEntries as $entry) { + $parentId = null; + $invoiceId = 0; + + $users = $this->userModel->getUsersBySchoolId($entry['parent_school_id']); + $user = $users[0] ?? null; + + // Always increment sync_attempts unless report-only + if (!$reportOnly) { + $this->paypalModel->update($entry['id'], [ + 'sync_attempts' => $entry['sync_attempts'] + 1 + ]); + } + + if ($user) { + $parentId = $user['id']; + + $invoice = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear); + + if (!$reportOnly && !$dryRun) { + if ($invoice) { + $invoiceId = $invoice['id']; + + $success = $this->processPayment( + $invoiceId, + $entry['amount'], + 'PayPal', + null, + $entry['transaction_id'], + date('Y-m-d', strtotime($entry['created_at'])), + $this->schoolYear, + $this->semester + ); + + if (!$success) { + $failed[] = $entry['transaction_id']; + continue; + } + } else { + $this->paymentModel->insert([ + 'parent_id' => $parentId, + 'invoice_id' => 0, + 'total_amount' => $entry['amount'], + 'paid_amount' => $entry['amount'], + 'balance' => 0.00, + 'number_of_installments' => 1, + 'transaction_id' => $entry['transaction_id'], + 'payment_method' => 'PayPal', + 'payment_date' => date('Y-m-d', strtotime($entry['created_at'])), + 'school_year' => $this->schoolYear, + 'semester' => $this->semester, + 'status' => 'Completed', + 'updated_by' => null, + ]); + } + + // Mark as synced only in LIVE mode + $this->paypalModel->update($entry['id'], ['synced' => 1]); + } + + $syncedCount++; + } else { + log_message('error', "[PAYPAL SYNC FAILED] No user found for parent_school_id: {$entry['parent_school_id']}"); + $failed[] = $entry['transaction_id']; + } + } + + // === Logging === + log_message('info', "[$mode] PAYPAL SYNC: $syncedCount processed."); + if (!empty($failed)) { + log_message('error', "[$mode] PAYPAL SYNC Failed: " . implode(', ', $failed)); + } + + // === CLI Output === + CLI::write("[$mode] $syncedCount PayPal payments processed.", 'green'); + if (!empty($failed)) { + CLI::error("[$mode] Failed transactions: " . implode(', ', $failed)); + } + + // === Email Report: Only if there's any update === + if ($syncedCount > 0 || !empty($failed)) { + helper('email'); + $email = \Config\Services::email(); + $email->setTo('support@alrahmaisgl.org'); + $email->setFrom('no-parentsreply@alrahmaisgl.org', 'PayPal Sync Report'); + $email->setSubject("[$mode] PayPal Sync Report - " . date('Y-m-d H:i')); + + $body = "PayPal Sync Mode: $mode\n\n"; + $body .= "$syncedCount PayPal payments processed.\n\n"; + + if (!empty($failed)) { + $body .= count($failed) . " failed transactions:\n"; + $body .= implode("\n", $failed); + } else { + $body .= "No failed transactions.\n"; + } + + $email->setMessage(nl2br($body)); + + if ($email->send()) { + CLI::write("[$mode] Email report sent successfully.", 'yellow'); + } else { + CLI::error("[$mode] Failed to send email report."); + log_message('error', 'Email send error: ' . $email->printDebugger(['headers'])); + } + } else { + log_message('info', "[$mode] No PayPal sync updates. Email not sent."); + CLI::write("[$mode] No changes to report. Email not sent.", 'blue'); + } + } + + private function processPayment($invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null) + { + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return false; + } + + $transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time(); + $paymentDate = $paymentDate ?? date('Y-m-d'); + + $newPaid = $invoice['paid_amount'] + $amount; + $newBalance = $invoice['balance'] - $amount; + + $invoiceUpdateData = [ + 'paid_amount' => $newPaid, + 'balance' => $newBalance, + 'status' => ($newBalance <= 0) ? 'Paid' : $invoice['status'], + ]; + + if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) { + return false; + } + + $this->paymentModel->insert([ + 'parent_id' => $invoice['parent_id'], + 'invoice_id' => $invoiceId, + 'total_amount' => $invoice['total_amount'], + 'paid_amount' => $amount, + 'balance' => $newBalance, + 'number_of_installments' => 1, + 'transaction_id' => $transactionId, + 'payment_method' => $paymentMethod, + 'payment_date' => $paymentDate, + 'status' => ($newBalance <= 0) ? 'Full' : 'Partial', + 'check_file' => $checkFile, + 'updated_by' => null, // Avoid using session()->get() in CLI + 'school_year' => $schoolYear, + 'semester' => $semester + ]); + + $this->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear); + return true; + } + + private function updateEnrollmentStatusIfPaid($invoiceId, $schoolYear) + { + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice || $invoice['balance'] > 0) { + return; + } + + $students = $this->studentModel->where('parent_id', $invoice['parent_id']) + ->where('school_year', $schoolYear) + ->findAll(); + + foreach ($students as $student) { + $this->enrollmentModel->set(['enrollment_status' => 'enrolled']) + ->where('student_id', $student['id']) + ->update(); + } + } +} diff --git a/app/old/EventController.php b/app/old/EventController.php deleted file mode 100644 index 442c46ea..00000000 --- a/app/old/EventController.php +++ /dev/null @@ -1,360 +0,0 @@ -eventChargesModel = new EventChargesModel(); //eventChargesModel - $this->studentModel = new StudentModel(); - $this->configModel = new ConfigurationModel(); - $this->userModel = new UserModel(); // Add this - $this->eventModel = new EventModel(); - $this->invoiceController = new InvoiceController(); - $this->enrollmentModel = new EnrollmentModel(); - - $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); - $this->categories = [ - 'workshops', - 'orientations', - 'field trips', - 'Ramadan programs', - ]; - } - - public function index() - { - $eventModel = new EventModel(); - - $today = local_date(utc_now(), 'Y-m-d'); - - // Fetch all events - $events = $eventModel - ->orderBy('created_at', 'DESC') - ->findAll(); - - // Fetch active events (not expired) - $activeEventCount = $eventModel - ->where('expiration_date >=', $today) - ->countAllResults(); - - return view('administrator/events/event_list', [ - 'events' => $events, - 'activeEventCount' => $activeEventCount - ]); - } - - - public function create() - { - helper(['form']); - if (strtolower($this->request->getMethod()) === 'post') { - $file = $this->request->getFile('flyer'); - $flyerPath = null; - - if ($file && $file->isValid() && !$file->hasMoved()) { - // Move to public/uploads/event_flyers - $newName = $file->getRandomName(); - $file->move(FCPATH . 'uploads/event_flyers', $newName); - $flyerPath = 'event_flyers/' . $newName; // store relative path - } - - $eventId = $this->eventModel->insert([ - 'event_name' => $this->request->getPost('event_name'), - 'event_category' => $this->request->getPost('event_category'), - 'description' => $this->request->getPost('description'), - 'amount' => $this->request->getPost('amount'), - 'flyer' => $flyerPath, - 'expiration_date' => $this->request->getPost('expiration_date'), - 'semester' => $this->request->getPost('semester'), - 'school_year' => $this->request->getPost('school_year'), - 'created_by' => session()->get('user_id'), - ]); - - if ($eventId) { - $amount = (float) $this->request->getPost('amount'); - $semester = (string) $this->request->getPost('semester'); - $schoolYear = (string) $this->request->getPost('school_year'); - $userId = (int) (session()->get('user_id') ?? 0); - - $enrollments = $this->enrollmentModel - ->select('enrollments.student_id, students.parent_id') - ->join('students', 'students.id = enrollments.student_id', 'left') - ->where('enrollments.school_year', $schoolYear) - ->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending']) - ->findAll(); - - $parentIds = []; - foreach ($enrollments as $row) { - $studentId = (int) ($row['student_id'] ?? 0); - $parentId = (int) ($row['parent_id'] ?? 0); - if ($studentId <= 0 || $parentId <= 0) { - continue; - } - - $exists = $this->eventChargesModel - ->where('event_id', $eventId) - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->first(); - - if ($exists) { - continue; - } - - $this->eventChargesModel->insert([ - 'event_id' => $eventId, - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'participation' => 'yes', - 'charged' => $amount, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'updated_by' => $userId ?: null, - ]); - - $parentIds[] = $parentId; - } - - $parentIds = array_unique($parentIds); - foreach ($parentIds as $pid) { - $this->invoiceController->generateInvoice((string) $pid); - } - } - - return redirect()->to('/administrator/events')->with('success', 'Event created successfully'); - } - - return view('administrator/events/create_event', [ - 'categories' => $this->categories, - ]); - } - - - public function edit($id = null) - { - helper(['form']); - $event = $this->eventModel->find($id); - - if (!$event) { - return redirect()->to('/administrator/events')->with('error', 'Event not found'); - } - - if (strtolower($this->request->getMethod()) === 'post') { - log_message('debug', 'POST detected'); - $file = $this->request->getFile('flyer'); - $flyerPath = $event['flyer']; // Default: keep old flyer - - if ($file && $file->isValid() && !$file->hasMoved()) { - $newName = $file->getRandomName(); - $file->move(FCPATH . 'uploads/event_flyers', $newName); - $flyerPath = 'event_flyers/' . $newName; // store relative path - } - - $updated = $this->eventModel->update($id, [ - 'event_name' => $this->request->getPost('event_name'), - 'event_category' => $this->request->getPost('event_category'), - 'description' => $this->request->getPost('description'), - 'amount' => $this->request->getPost('amount'), - 'flyer' => $flyerPath, - 'expiration_date' => $this->request->getPost('expiration_date'), - 'semester' => $this->request->getPost('semester'), - 'school_year' => $this->request->getPost('school_year'), - ]); - - if ($updated) { - return redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); - } else { - log_message('debug', 'GET detected'); - return redirect()->back()->with('error', 'Failed to update event'); - } - } - - return view('administrator/events/edit_event', [ - 'event' => $event, - 'categories' => $this->categories, - ]); - } - - - public function delete($id = null) - { - - $event = $this->eventModel->find($id); - - if (!$event) { - return redirect()->to('/administrator/events')->with('error', 'Event not found'); - } - - // Delete related charges and collect parent IDs - $charges = $this->eventChargesModel->where('event_id', $id)->findAll(); - $parentIds = []; - - foreach ($charges as $charge) { - $this->eventChargesModel->delete($charge['id']); - $parentIds[] = $charge['parent_id']; - } - - // Delete event - $this->eventModel->delete($id); - - $parentIds = array_unique($parentIds); - - foreach ($parentIds as $parentId) { - $this->invoiceController->generateInvoice($parentId); - } - - return redirect()->to('/administrator/events')->with('success', 'Event, charges, and invoices updated.'); - } - - - // Optionally keep your eventShow / eventUpdate for legacy administrator event charges - public function eventShow() - { - - $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - $semester = $this->request->getGet('semester') ?? $this->semester; - - $parents = $this->userModel->getParents(); - $events = $this->eventModel->getActiveEvents($this->schoolYear); - - $charges = $this->eventChargesModel - ->select('event_charges.*, - users.firstname AS parent_firstname, users.lastname AS parent_lastname, - students.firstname AS student_firstname, students.lastname AS student_lastname, - events.event_name') - ->join('users', 'users.id = event_charges.parent_id', 'left') - ->join('students', 'students.id = event_charges.student_id', 'left') - ->join('events', 'events.id = event_charges.event_id', 'left') - ->where('event_charges.school_year', $schoolYear) - ->where('event_charges.semester', $semester) - ->orderBy('event_charges.created_at', 'DESC') - ->findAll(); - - return view('administrator/events/event_charges', [ - 'charges' => $charges, - 'parents' => $parents, - 'events' => $events, - 'school_year' => $schoolYear, - 'semester' => $semester, - ]); - } - - - public function eventUpdate() - { - $schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear; - $semester = $this->request->getPost('semester') ?? $this->semester; - - $parentId = $this->request->getPost('parent_id'); - $eventId = $this->request->getPost('event_id'); - $participations = $this->request->getPost('participation') ?? []; - - if (!$parentId || !$eventId || empty($participations)) { - return redirect()->back()->with('error', 'Missing required information.'); - } - - $userId = session()->get('user_id'); - $event = $this->eventModel->getEvent($eventId, $schoolYear); - - foreach ($participations as $studentId => $value) { - $existing = $this->eventChargesModel->where([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - 'school_year' => $schoolYear, - 'semester' => $semester - ])->first(); - - if ($value === 'no') { - if ($existing) { - $this->eventChargesModel->delete($existing['id']); - } - continue; - } - - // value is 'yes' - if ($existing) { - $this->eventChargesModel->update($existing['id'], [ - 'participation' => 'yes', - 'charged' => $event['amount'], - 'updated_by' => $userId - ]); - } else { - $this->eventChargesModel->insert([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - 'participation' => 'yes', - 'charged' => $event['amount'], - 'school_year' => $schoolYear, - 'semester' => $semester, - 'created_by' => $userId, - 'updated_by' => $userId - ]); - } - } - - $this->invoiceController->generateInvoice($parentId); - - return redirect()->back()->with('success', 'Event charges updated successfully.'); - } - - - - - public function getStudentsWithCharges() - { - $parentId = $this->request->getGet('parent_id'); - $semester = $this->request->getGet('semester'); - $schoolYear = $this->request->getGet('school_year'); - - // Get students for parent - $students = $this->studentModel->where('parent_id', $parentId)->findAll(); - - // Get student_ids that already have charges - $chargedStudentIds = $this->eventChargesModel - ->where('parent_id', $parentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->groupBy('student_id') - ->select('student_id') - ->findColumn('student_id'); - - $data = []; - foreach ($students as $student) { - $data[] = [ - 'id' => $student['id'], - 'name' => $student['firstname'] . ' ' . $student['lastname'], - 'charged' => in_array($student['id'], $chargedStudentIds ?? []), - ]; - } - - return $this->response->setJSON($data); - } - - -} diff --git a/app/old/Events/DeleteUnverifiedUser.php b/app/old/Events/DeleteUnverifiedUser.php new file mode 100644 index 00000000..46175e88 --- /dev/null +++ b/app/old/Events/DeleteUnverifiedUser.php @@ -0,0 +1,46 @@ +userId = $userId; + } + + public static function deleteAfterTimeout($userId) + { + $model = new self(); + + // Define the timeout period + $timeoutPeriod = 24 * 60 * 60; // 1 day in seconds + + // Retrieve the user's record + $user = $model->find($userId); + + if ($user) { + // Get the most recent timestamp to compare with the current time + $lastActivityTime = strtotime($user['updated_at'] ?? $user['created_at']); + + // Calculate the time difference + $timeSinceLastActivity = time() - $lastActivityTime; + + // Check if the time difference exceeds the timeout period + if ($timeSinceLastActivity > $timeoutPeriod) { + // Delete the user record + return $model->delete($userId); + } else { + // Timeout period has not yet passed + return false; // Or you can return a message indicating no action taken + } + } + + // Return false if the user is not found + return false; + } + +} + diff --git a/app/old/Filters/.gitkeep b/app/old/Filters/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/old/Filters/ApiAuthFilter.php b/app/old/Filters/ApiAuthFilter.php new file mode 100644 index 00000000..a91c3f9c --- /dev/null +++ b/app/old/Filters/ApiAuthFilter.php @@ -0,0 +1,59 @@ +getHeaderLine('Authorization'); + if (!$authorization || stripos($authorization, 'Bearer ') !== 0) { + return $this->unauthorized('Missing or invalid Authorization header'); + } + + $token = trim(substr($authorization, 7)); + if ($token === '') { + return $this->unauthorized('Bearer token is required'); + } + + $secret = env('JWT_SECRET', 'change-me-in-env'); + $payload = jwt_decode($token, $secret); + + if (!$payload || empty($payload['sub'])) { + return $this->unauthorized('Invalid or malformed token'); + } + + if (isset($payload['exp']) && (int) $payload['exp'] < time()) { + return $this->unauthorized('Token has expired'); + } + + $session = Services::session(); + $session->start(); + + $session->set([ + 'user_id' => (int) $payload['sub'], + 'roles' => (array) ($payload['roles'] ?? []), + ]); + + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + return $response; + } + + private function unauthorized(string $message) + { + $response = Services::response(); + return $response->setStatusCode(401)->setJSON([ + 'status' => false, + 'message' => $message, + ]); + } +} diff --git a/app/old/Filters/ApiDocsAuthFilter.php b/app/old/Filters/ApiDocsAuthFilter.php new file mode 100644 index 00000000..4938b9ed --- /dev/null +++ b/app/old/Filters/ApiDocsAuthFilter.php @@ -0,0 +1,55 @@ +getHeaderLine('Authorization'); + + if ($authHeader && str_starts_with($authHeader, 'Bearer ')) { + $token = trim(substr($authHeader, 7)); + + try { + $key = getenv('JWT_SECRET') ?: 'your_default_secret'; + $decoded = JWT::decode($token, new Key($key, 'HS256')); + + if (!isset($decoded->roles) || empty($decoded->roles->admin)) { + return $this->denyAccess('Admin privileges required.', ResponseInterface::HTTP_FORBIDDEN); + } + } catch (\Throwable $e) { + return $this->denyAccess('Invalid or expired token.', ResponseInterface::HTTP_UNAUTHORIZED); + } + } else { + $session = session(); + if (!$session->get('isLoggedIn') || $session->get('role') !== 'admin') { + if (strpos($request->getHeaderLine('Accept'), 'text/html') !== false) { + return redirect()->to(base_url('login'))->with('error', 'Please log in to access API documentation.'); + } + return $this->denyAccess('Unauthorized access to documentation.', ResponseInterface::HTTP_UNAUTHORIZED); + } + } + } + + private function denyAccess(string $message, int $status) + { + return Services::response() + ->setJSON([ + 'status' => false, + 'message' => $message, + ]) + ->setStatusCode($status); + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + } +} diff --git a/app/old/Filters/AuthFilter.php b/app/old/Filters/AuthFilter.php new file mode 100644 index 00000000..f3fcae66 --- /dev/null +++ b/app/old/Filters/AuthFilter.php @@ -0,0 +1,200 @@ + ['permission' => 'nav_builder_access', 'crud' => 'read'], + // 'admin/enrollment/new-students' => ['permission' => 'view_new_students', 'crud' => 'read'], + // 'administrator/contact_information' => ['permission' => 'view_contact_info', 'crud' => 'read'], + // You can also allow specific role IDs directly: + // 'some/admin/page' => ['roles' => [1, 2]], // role_id 1 or 2 + ]; + + public function __construct() + { + $this->db = \Config\Database::connect(); + } + + public function before(RequestInterface $request, $arguments = null) + { + $session = session(); + $userRoles = $session->get('roles'); // not used here, but keep if other code expects it + $userId = $session->get('user_id'); + $loginTime = (int) $session->get('login_time'); + + // Must be logged in + if (empty($userRoles) || empty($userId)) { + return redirect()->to('/login'); + } + + // Enforce 12-hour session lifetime + if ($loginTime <= 0 || (time() - $loginTime) >= self::LOGIN_TTL) { + return $this->handleExpiredSession($request); + } + + // Load role IDs for this user + $roleIdRows = $this->db->table('user_roles') + ->select('role_id') + ->where('user_id', $userId) + ->get()->getResultArray(); + + $roleIds = array_map('intval', array_column($roleIdRows, 'role_id')); + if (empty($roleIds)) { + return $this->deny($request, "You don't have permission to use this feature."); + } + + // Route arguments: ['filter' => 'auth:permission_name|alt_permission,update'] + // Route arguments patterns supported: + // auth -> default 'read' + // auth:read -> CRUD only + // auth:create -> CRUD only + // auth:update -> CRUD only + // auth:delete -> CRUD only + // auth:permA|permB,read -> permission + CRUD + $requiredPermission = null; + $crudAction = 'read'; + + if (!empty($arguments)) { + $arg0 = strtolower((string) $arguments[0]); + + // If only one argument and it's a CRUD keyword, treat as CRUD-only + if (count($arguments) === 1 && in_array($arg0, ['create', 'read', 'update', 'delete'], true)) { + $crudAction = $arg0; + } else { + // Otherwise, first arg is permission(s); optional second is CRUD + $requiredPermission = $arguments[0]; // e.g., 'edit_student|manage_students' + $crudAction = isset($arguments[1]) ? strtolower((string) $arguments[1]) : 'read'; + if (!in_array($crudAction, ['create', 'read', 'update', 'delete'], true)) { + $crudAction = 'read'; + } + } + } + + + // No explicit route permission: fall back to menu rules + if ($this->isAllowedByMenu($request, $roleIds)) { + return; // ✅ allowed + } + + return $this->deny($request, "You don't have permission to use this feature."); + } + + private function handleExpiredSession(RequestInterface $request) + { + session()->destroy(); + + if ($request->isAJAX() || $request->getHeaderLine('Accept') === 'application/json') { + return service('response') + ->setStatusCode(401) + ->setJSON([ + 'status' => 'expired', + 'message' => 'Your session has expired. Please log in again.', + 'redirect' => '/login', + ]); + } + + return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.'); + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // No-op + } + + /** + * JSON for AJAX, redirect for normal requests. + * Use RequestInterface here (your version used IncomingRequest which was too specific). + */ + public function deny(RequestInterface $request, string $message) + { + if ($request->isAJAX() || $request->getHeaderLine('Accept') === 'application/json') { + return service('response') + ->setStatusCode(403) + ->setJSON(['status' => 'error', 'message' => $message]); + } + + session()->setFlashdata('error', $message); + return redirect()->to('/access_denied'); + } + + /** + * Checks roles against CRUD flags on a role_permissions row. + */ + private function hasPermission(array $rolePermission, string $crudAction): bool + { + switch ($crudAction) { + case 'create': + return !empty($rolePermission['can_create']); + case 'read': + return !empty($rolePermission['can_read']); + case 'update': + return !empty($rolePermission['can_update']); + case 'delete': + return !empty($rolePermission['can_delete']); + default: + return false; + } + } + + /** + * ✅ Missing method added: + * Allow/deny based on $menuRules and the user's role IDs / permissions. + * Defaults to ALLOW if the path has no rule. + */ + private function isAllowedByMenu(RequestInterface $request, array $roleIds): bool + { + // ❌ was: $request->uri->getPath() + $path = ltrim(preg_replace('#/+#', '/', $request->getUri()->getPath()), '/'); + + $rule = $this->menuRules[$path] ?? null; + + if ($rule === null && str_starts_with($path, 'admin/')) { + $rule = $this->menuRules[substr($path, 6)] ?? null; + } + + if ($rule === null) { + return true; // or false if you prefer default-deny + } + + if (!empty($rule['roles']) && is_array($rule['roles'])) { + foreach ($roleIds as $rid) { + if (in_array((int)$rid, $rule['roles'], true)) return true; + } + // fall through to permission check (if present) + } + + if (!empty($rule['permission'])) { + $crud = $rule['crud'] ?? 'read'; + $rpRows = $this->db->table('role_permissions rp') + ->join('permissions p', 'p.id = rp.permission_id') + ->select('rp.*') + ->whereIn('rp.role_id', $roleIds) + ->where('p.name', $rule['permission']) + ->get()->getResultArray(); + + foreach ($rpRows as $rp) { + if ($this->hasPermission($rp, $crud)) return true; + } + return false; + } + + return true; + } +} diff --git a/app/old/Filters/CleanupScheduler.php b/app/old/Filters/CleanupScheduler.php new file mode 100644 index 00000000..555a8d71 --- /dev/null +++ b/app/old/Filters/CleanupScheduler.php @@ -0,0 +1,36 @@ +difference($lastRun)->getMinutes() >= 2) { + // Trigger the cleanup + $this->runCleanup(); + + // Update the last run time + cache()->save('last_cleanup_run', Time::now()); + } + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // Do nothing after the request + } + + private function runCleanup() + { + // Call the cleanup controller method + \CodeIgniter\CLI\CLI::init(); + command('cleanup:unverified_users'); + } +} diff --git a/app/old/Filters/PermissionFilter.php b/app/old/Filters/PermissionFilter.php new file mode 100644 index 00000000..69b77165 --- /dev/null +++ b/app/old/Filters/PermissionFilter.php @@ -0,0 +1,31 @@ +get('is_logged_in')) { + return redirect()->to('/user/login'); + } + + $required = $arguments[0] ?? null; + if ($required) { + $perms = session('permissions') ?? []; + if (! in_array($required, $perms, true)) { + return redirect()->to('/')->with('error', 'Access denied.'); + } + } + + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + } +} diff --git a/app/old/Filters/TimezoneFilter.php b/app/old/Filters/TimezoneFilter.php new file mode 100644 index 00000000..f11b8cc7 --- /dev/null +++ b/app/old/Filters/TimezoneFilter.php @@ -0,0 +1,26 @@ +primeFromRequest($request); + } catch (\Throwable $e) { + // ignore; fallback detection will still work lazily + } + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // No-op + } +} + diff --git a/app/old/Helpers/.gitkeep b/app/old/Helpers/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/old/Helpers/AuthHelper.php b/app/old/Helpers/AuthHelper.php new file mode 100644 index 00000000..d4e58111 --- /dev/null +++ b/app/old/Helpers/AuthHelper.php @@ -0,0 +1,28 @@ +where('name', $permissionName)->first(); + if (!$permission) { + return false; + } + + $permissionId = $permission['id']; + $roles = $userRoleModel->where('user_id', $userId)->findAll(); + foreach ($roles as $role) { + $permissions = $rolePermissionModel->where('role_id', $role['role_id'])->where('permission_id', $permissionId)->findAll(); + if (!empty($permissions)) { + return true; + } + } + + return false; +} diff --git a/app/old/Helpers/DocumentHelper.php b/app/old/Helpers/DocumentHelper.php new file mode 100644 index 00000000..e69de29b diff --git a/app/old/Helpers/GlobalConfigHelper.php b/app/old/Helpers/GlobalConfigHelper.php new file mode 100644 index 00000000..a94ec508 --- /dev/null +++ b/app/old/Helpers/GlobalConfigHelper.php @@ -0,0 +1,18 @@ +getConfig('semester'); + } +} + +if (!function_exists('getSchoolYear')) { + function getSchoolYear() { + $configModel = new \App\Models\ConfigurationModel(); + return $configModel->getConfig('school_year'); + } +} diff --git a/app/old/Helpers/attendance_comment_helper.php b/app/old/Helpers/attendance_comment_helper.php new file mode 100644 index 00000000..5ea47e7c --- /dev/null +++ b/app/old/Helpers/attendance_comment_helper.php @@ -0,0 +1,60 @@ +getActiveTemplates(); + } catch (\Throwable $e) { + $templates = []; + } + } + + foreach ($templates as $template) { + $min = isset($template['min_score']) ? (float) $template['min_score'] : 0.0; + $max = isset($template['max_score']) ? (float) $template['max_score'] : 100.0; + if ($score >= $min && $score <= $max) { + return $template; + } + } + + return null; + } +} diff --git a/app/old/Helpers/jwt_helper.php b/app/old/Helpers/jwt_helper.php new file mode 100644 index 00000000..fafec765 --- /dev/null +++ b/app/old/Helpers/jwt_helper.php @@ -0,0 +1,86 @@ + 'JWT', 'alg' => $alg]; + + $segments = []; + $segments[] = base64url_encode(json_encode($header, JSON_UNESCAPED_SLASHES)); + $segments[] = base64url_encode(json_encode($payload, JSON_UNESCAPED_SLASHES)); + + $signingInput = implode('.', $segments); + + switch ($alg) { + case 'HS256': + $signature = hash_hmac('sha256', $signingInput, $secret, true); + break; + default: + throw new \InvalidArgumentException('Unsupported JWT alg: ' . $alg); + } + + $segments[] = base64url_encode($signature); + return implode('.', $segments); + } +} + +if (!function_exists('base64url_decode')) { + function base64url_decode(string $data): string + { + $padding = strlen($data) % 4; + if ($padding > 0) { + $data .= str_repeat('=', 4 - $padding); + } + return base64_decode(strtr($data, '-_', '+/')); + } +} + +if (!function_exists('jwt_decode')) { + function jwt_decode(string $token, string $secret, string $alg = 'HS256'): ?array + { + $parts = explode('.', $token); + if (count($parts) !== 3) { + return null; + } + + [$header64, $payload64, $signature64] = $parts; + $header = json_decode(base64url_decode($header64), true); + $payload = json_decode(base64url_decode($payload64), true); + $signature = base64url_decode($signature64); + + if (!is_array($header) || !is_array($payload)) { + return null; + } + + $signingInput = $header64 . '.' . $payload64; + + switch ($header['alg'] ?? $alg) { + case 'HS256': + $expected = hash_hmac('sha256', $signingInput, $secret, true); + break; + default: + return null; + } + + if (!hash_equals($expected, $signature)) { + return null; + } + + return $payload; + } +} diff --git a/app/old/Helpers/pbkdf2_helper.php b/app/old/Helpers/pbkdf2_helper.php new file mode 100644 index 00000000..bb91cb0d --- /dev/null +++ b/app/old/Helpers/pbkdf2_helper.php @@ -0,0 +1,39 @@ +get('teacher_scores_selected_semester') ?? '')); + if ($selected !== '') { + return ucfirst(strtolower($selected)); + } + + $fallback = trim((string) ($session->get('semester') ?? '')); + if ($fallback !== '') { + return ucfirst(strtolower($fallback)); + } + + $config = new ConfigurationModel(); + $configSemester = trim((string) ($config->getConfig('semester') ?? '')); + if ($configSemester !== '') { + return ucfirst(strtolower($configSemester)); + } + + return 'Fall'; + } +} diff --git a/app/old/Helpers/time_helper.php b/app/old/Helpers/time_helper.php new file mode 100644 index 00000000..ce58f0f6 --- /dev/null +++ b/app/old/Helpers/time_helper.php @@ -0,0 +1,75 @@ +userTimezone(); + } +} + +if (! function_exists('server_timezone')) { + function server_timezone(): string + { + return service('timeService')->serverTimezone(); + } +} + +if (! function_exists('utc_now')) { + function utc_now(string $format = 'Y-m-d H:i:s'): string + { + return service('timeService')->nowUTC()->format($format); + } +} + +if (! function_exists('local_now')) { + function local_now(string $format = 'Y-m-d H:i:s', ?string $tz = null): string + { + return service('timeService')->nowLocal($tz)->format($format); + } +} + +if (! function_exists('to_utc')) { + /** + * Convert a time to UTC with optional source TZ and format. + * + * @param string|Time|\DateTimeInterface|null $value + */ + function to_utc($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string + { + return service('timeService')->toUTC($value, $fromTz, $format); + } +} + +if (! function_exists('to_local')) { + /** + * Convert a time to user-local with optional source TZ and format. + * + * @param string|Time|\DateTimeInterface|null $value + */ + function to_local($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string + { + return service('timeService')->toLocal($value, $sourceTz, $targetTz, $format); + } +} + +if (! function_exists('local_date')) { + /** + * Format to user-local date (Y-m-d by default). Assumes input is UTC unless source TZ provided. + */ + function local_date($value, string $format = 'Y-m-d', ?string $sourceTz = null): string + { + return service('timeService')->formatLocal($value, $format, $sourceTz); + } +} + +if (! function_exists('local_datetime')) { + /** + * Format to user-local datetime (Y-m-d H:i by default). Assumes input is UTC unless source TZ provided. + */ + function local_datetime($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string + { + return service('timeService')->formatLocal($value, $format, $sourceTz); + } +} diff --git a/app/old/Interfaces/ScoreCalculatorInterface.php b/app/old/Interfaces/ScoreCalculatorInterface.php new file mode 100644 index 00000000..2e4aa518 --- /dev/null +++ b/app/old/Interfaces/ScoreCalculatorInterface.php @@ -0,0 +1,17 @@ + $userId, - 'title' => $title, - 'message' => $message, - 'channels' => $channels - ]); - } -} \ No newline at end of file diff --git a/app/old/NotificationsController.php b/app/old/NotificationsController.php deleted file mode 100644 index fc55590d..00000000 --- a/app/old/NotificationsController.php +++ /dev/null @@ -1,213 +0,0 @@ -notificationModel = new NotificationModel(); - $this->userNotificationModel = new UserNotificationModel(); - $this->userModel = new UserModel(); - } - - public function index() - { - $userId = session()->get('user_id'); - - $notifications = $this->userNotificationModel - ->select('notifications.*, user_notifications.is_read') - ->join('notifications', 'notifications.id = user_notifications.notification_id') - ->where('user_notifications.user_id', $userId) - ->orderBy('notifications.created_at', 'DESC') - ->findAll(); - - return view('notifications/index', ['notifications' => $notifications]); - } - - public function create() - { - return view('notifications/create'); - } - - public function send() - { - $title = $this->request->getPost('title'); - $message = $this->request->getPost('message'); - $group = $this->request->getPost('target_group'); - $channels = $this->request->getPost('channels'); // ['in_app', 'email', 'sms'] - - $notifId = $this->notificationModel->insert([ - 'title' => $title, - 'message' => $message, - 'target_group' => $group, - 'delivery_channels' => implode(',', $channels), - 'created_at' => utc_now() - ]); - - $users = $this->userModel->where('role', $group)->findAll(); - - foreach ($users as $user) { - $this->userNotificationModel->insert([ - 'notification_id' => $notifId, - 'user_id' => $user['id'], - 'is_read' => false - ]); - - if (in_array('email', $channels)) { - $this->sendEmail($user['email'], $title, $message); - } - - if (in_array('sms', $channels) && !empty($user['phone'])) { - $this->sendSMS($user['phone'], $message); - } - } - - return redirect()->back()->with('success', 'Notification sent successfully.'); - } - - public function markAsRead($id) - { - $userId = session()->get('user_id'); - $this->userNotificationModel->where('notification_id', $id) - ->where('user_id', $userId) - ->set(['is_read' => 1]) - ->update(); - - return redirect()->back(); - } - - public function listActive() - { - helper('url'); - - $targetGroup = $this->request->getGet('target_group'); - - return view('notifications/list_active', [ - 'notificationsEndpoint' => site_url('api/notifications/active'), - 'defaultTargetGroup' => $targetGroup, - ]); - } - - public function listDeleted() - { - helper(['url', 'form']); - - return view('notifications/list_deleted', [ - 'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'), - 'restoreEndpoint' => site_url('notifications/restore'), - 'csrfTokenName' => csrf_token(), - 'csrfTokenValue' => csrf_hash(), - ]); - } - - public function activeNotificationsData() - { - $targetGroup = $this->request->getGet('target_group'); - - return $this->response->setJSON($this->buildActiveNotificationsPayload($targetGroup)); - } - - public function deletedNotificationsData() - { - return $this->response->setJSON($this->buildDeletedNotificationsPayload()); - } - - public function restore($id) - { - if ($this->notificationModel->restoreNotification($id)) { - return redirect()->to(base_url('notifications/deleted'))->with('success', 'Notification restored successfully.'); - } - - return redirect()->to(base_url('notifications/deleted'))->with('error', 'Failed to restore notification.'); - } - - protected function sendEmail($to, $subject, $message) - { - $mailer = new EmailController(); - $mailer->sendEmail($to, $subject, $message); - } - - protected function sendSMS($phone, $message) - { - log_message('info', "SMS to {$phone}: {$message}"); - } - - private function buildActiveNotificationsPayload(?string $targetGroup): array - { - $targetGroup = $targetGroup !== null ? trim($targetGroup) : null; - if ($targetGroup === '') { - $targetGroup = null; - } - - $rows = $this->notificationModel->getActiveNotifications($targetGroup); - if (!is_array($rows)) { - $rows = []; - } - - $now = time(); - - $notifications = array_map(static function ($row) use ($now) { - if (!is_array($row)) { - return []; - } - - $expiresAt = $row['expires_at'] ?? null; - $expiryTs = $expiresAt ? strtotime((string) $expiresAt) : false; - $isExpired = $expiryTs !== false && $expiryTs <= $now; - - return [ - 'id' => $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, - ]; - } - - private function buildDeletedNotificationsPayload(): array - { - $rows = $this->notificationModel->getDeletedNotifications(); - if (!is_array($rows)) { - $rows = []; - } - - $notifications = array_map(static function ($row) { - if (!is_array($row)) { - return []; - } - - return [ - 'id' => $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/bootstrap/app.php b/bootstrap/app.php index c3928c57..85d31086 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,11 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'auth.multi' => \App\Http\Middleware\MultiAuth::class, + 'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class, + 'jwt.refresh' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\RefreshToken::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d1..86f5297a 100755 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\EventServiceProvider::class, ]; diff --git a/database/migrations/2026_03_11_045324_create_cache_table.php b/database/migrations/2026_03_11_045324_create_cache_table.php new file mode 100644 index 00000000..ed758bdf --- /dev/null +++ b/database/migrations/2026_03_11_045324_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/routes/api.php b/routes/api.php index 26708fd7..f3cb36a6 100755 --- a/routes/api.php +++ b/routes/api.php @@ -107,6 +107,7 @@ use App\Http\Controllers\Api\Messaging\WhatsappController; use App\Http\Controllers\Api\Subjects\SubjectCurriculumController as SubjectCurriculumApiController; use App\Http\Controllers\Api\Exams\ExamDraftController as ExamDraftApiController; use App\Http\Controllers\Api\Settings\ConfigurationAdminController; +use App\Http\Controllers\Api\Notifications\NotificationController as ApiNotificationController; use App\Http\Controllers\Api\Attendance\AdminAttendanceApiController; use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController; @@ -117,9 +118,13 @@ Route::prefix('v1')->group(function () { Route::prefix('auth')->group(function () { Route::get('register/captcha', [RegisterController::class, 'captcha']); Route::post('register', [RegisterController::class, 'store']); + Route::post('login', [AuthController::class, 'login']); + Route::post('refresh', [AuthController::class, 'refresh'])->middleware('jwt.auth'); + Route::post('logout', [AuthController::class, 'logout'])->middleware('auth.multi'); + Route::get('me', [AuthController::class, 'me'])->middleware('auth.multi'); }); - Route::middleware('auth:sanctum')->prefix('administrator')->group(function () { + Route::middleware('auth.multi')->prefix('administrator')->group(function () { Route::get('absence', [AdministratorAbsenceController::class, 'index']); Route::post('absence', [AdministratorAbsenceController::class, 'store']); @@ -148,7 +153,7 @@ Route::prefix('v1')->group(function () { Route::delete('emergency-contacts/{contactId}', [AdministratorEmergencyContactController::class, 'destroy']); }); - Route::middleware('auth:sanctum')->prefix('attendance')->group(function () { + Route::middleware('auth.multi')->prefix('attendance')->group(function () { // Teacher Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']); Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']); @@ -185,8 +190,19 @@ Route::prefix('v1')->group(function () { Route::get('/class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']); }); - Route::middleware('auth:sanctum')->group(function () { + Route::middleware('auth.multi')->group(function () { Route::get('stats', [StatsController::class, 'index']); + Route::prefix('notifications')->group(function () { + Route::get('/', [ApiNotificationController::class, 'index']); + Route::post('/', [ApiNotificationController::class, 'store']); + Route::get('active', [ApiNotificationController::class, 'active']); + Route::get('deleted', [ApiNotificationController::class, 'deleted']); + Route::get('{notificationId}', [ApiNotificationController::class, 'show']); + Route::patch('{notificationId}', [ApiNotificationController::class, 'update']); + Route::delete('{notificationId}', [ApiNotificationController::class, 'destroy']); + Route::post('{notificationId}/restore', [ApiNotificationController::class, 'restore']); + Route::post('{notificationId}/read', [ApiNotificationController::class, 'markRead']); + }); Route::prefix('session')->group(function () { Route::get('timeout-config', [SessionTimeoutController::class, 'config'])->name('session.timeout.config'); Route::get('check-timeout', [SessionTimeoutController::class, 'check'])->name('session.timeout.check'); @@ -699,7 +715,7 @@ Route::prefix('attendance-tracking')->group(function () { Route::post('save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']); }); -Route::middleware('auth:sanctum')->group(function () { +Route::middleware('auth.multi')->group(function () { Route::prefix('assignments')->group(function () { Route::get('/', [AssignmentApiController::class, 'index']); Route::post('/', [AssignmentApiController::class, 'store']); diff --git a/school_api b/school_api index 5a91352b..0515241f 100644 Binary files a/school_api and b/school_api differ diff --git a/tests/Feature/Api/V1/Notifications/NotificationControllerTest.php b/tests/Feature/Api/V1/Notifications/NotificationControllerTest.php new file mode 100644 index 00000000..e31615ba --- /dev/null +++ b/tests/Feature/Api/V1/Notifications/NotificationControllerTest.php @@ -0,0 +1,309 @@ +getJson('/api/v1/notifications'); + + $response->assertStatus(401); + } + + public function test_index_returns_notifications(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Hello', + 'message' => 'World', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_notifications')->insert([ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 0, + ]); + + $response = $this->getJson('/api/v1/notifications'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure(['ok', 'notifications', 'pagination']); + } + + public function test_show_returns_notification(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Hello', + 'message' => 'World', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_notifications')->insert([ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 0, + ]); + + $response = $this->getJson('/api/v1/notifications/1'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure(['ok', 'notification']); + } + + public function test_store_creates_notification(): void + { + $this->seedRole('parent'); + + DB::table('users')->insert([ + 'id' => 2, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'parent@example.com', + 'status' => 'Active', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 2, + 'role_id' => 1, + ]); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/notifications', [ + 'title' => 'Announcement', + 'message' => 'Hello parents', + 'target_group' => 'parent', + 'channels' => ['in_app'], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('ok', true); + $this->assertDatabaseHas('notifications', [ + 'title' => 'Announcement', + ]); + $this->assertDatabaseHas('user_notifications', [ + 'user_id' => 2, + ]); + } + + public function test_store_validates_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/notifications', [ + 'title' => '', + 'message' => '', + 'target_group' => '', + 'channels' => ['invalid'], + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['message', 'errors']); + } + + public function test_store_handles_service_exception(): void + { + $this->mock(NotificationSendService::class, function ($mock) { + $mock->shouldReceive('send')->andThrow(new \RuntimeException('boom')); + }); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/notifications', [ + 'title' => 'Announcement', + 'message' => 'Hello parents', + 'target_group' => 'parent', + 'channels' => ['in_app'], + ]); + + $response->assertStatus(500); + $response->assertJsonPath('ok', false); + } + + public function test_mark_read_updates_user_notification(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Hello', + 'message' => 'World', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_notifications')->insert([ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 0, + ]); + + $response = $this->postJson('/api/v1/notifications/1/read'); + + $response->assertOk(); + $this->assertDatabaseHas('user_notifications', [ + 'notification_id' => 1, + 'user_id' => 1, + 'is_read' => 1, + ]); + } + + public function test_active_and_deleted_lists(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Active', + 'message' => 'Now', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'scheduled_at' => now()->subDay(), + 'expires_at' => now()->addDay(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('notifications')->insert([ + 'id' => 2, + 'title' => 'Deleted', + 'message' => 'Old', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'deleted_at' => now(), + 'created_at' => now()->subDays(2), + 'updated_at' => now()->subDays(2), + ]); + + $active = $this->getJson('/api/v1/notifications/active'); + $active->assertOk(); + $active->assertJsonPath('ok', true); + + $deleted = $this->getJson('/api/v1/notifications/deleted'); + $deleted->assertOk(); + $deleted->assertJsonPath('ok', true); + } + + public function test_update_and_delete_notification(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Old', + 'message' => 'Old', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $update = $this->patchJson('/api/v1/notifications/1', [ + 'title' => 'New', + ]); + $update->assertOk(); + $this->assertDatabaseHas('notifications', [ + 'id' => 1, + 'title' => 'New', + ]); + + $delete = $this->deleteJson('/api/v1/notifications/1'); + $delete->assertOk(); + $this->assertSoftDeleted('notifications', ['id' => 1]); + } + + public function test_restore_notification(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('notifications')->insert([ + 'id' => 1, + 'title' => 'Old', + 'message' => 'Old', + 'target_group' => 'parent', + 'delivery_channels' => json_encode(['in_app']), + 'deleted_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->postJson('/api/v1/notifications/1/restore'); + + $response->assertOk(); + $this->assertDatabaseHas('notifications', [ + 'id' => 1, + 'deleted_at' => null, + ]); + } + + private function seedRole(string $name): void + { + DB::table('roles')->insert([ + 'id' => 1, + 'name' => $name, + 'slug' => $name, + 'is_active' => 1, + ]); + } + + private function createUser(): User + { + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return User::query()->findOrFail(1); + } +} diff --git a/tests/Feature/Console/AttendanceCommandsTest.php b/tests/Feature/Console/AttendanceCommandsTest.php new file mode 100644 index 00000000..7f360cfe --- /dev/null +++ b/tests/Feature/Console/AttendanceCommandsTest.php @@ -0,0 +1,59 @@ +shouldReceive('run')->once()->andReturn([ + 'checked_at' => '2025-03-01 10:00:00', + 'timezone' => 'UTC', + 'published' => 0, + ]); + + $this->app->instance(AttendanceAutoPublishJobService::class, $service); + + $this->artisan('attendance:auto-publish')->assertExitCode(0); + } + + public function test_attendance_recalculate_summary_command_runs(): void + { + $service = Mockery::mock(AttendanceSummaryRebuildService::class); + $service->shouldReceive('rebuild')->once()->andReturn(['inserted' => 3]); + + $this->app->instance(AttendanceSummaryRebuildService::class, $service); + + $this->artisan('attendance:recalculate-summary')->assertExitCode(0); + } + + public function test_attendance_absentees_summary_command_runs(): void + { + $service = Mockery::mock(AttendanceDailySummaryService::class); + $service->shouldReceive('sendAbsenteesSummary')->once()->andReturn(2); + + $this->app->instance(AttendanceDailySummaryService::class, $service); + + $this->artisan('attendance:absentees-summary')->assertExitCode(0); + } + + public function test_attendance_lates_summary_command_runs(): void + { + $service = Mockery::mock(AttendanceDailySummaryService::class); + $service->shouldReceive('sendLatesSummary')->once()->andReturn(1); + + $this->app->instance(AttendanceDailySummaryService::class, $service); + + $this->artisan('attendance:lates-summary')->assertExitCode(0); + } +} diff --git a/tests/Feature/Console/PaymentsCommandsTest.php b/tests/Feature/Console/PaymentsCommandsTest.php new file mode 100644 index 00000000..d40621db --- /dev/null +++ b/tests/Feature/Console/PaymentsCommandsTest.php @@ -0,0 +1,74 @@ +shouldReceive('findUsersWithMissedPayments')->once()->andReturn([]); + + $this->app->instance(PaymentMissedCheckService::class, $service); + + $this->artisan('payments:check-missed')->assertExitCode(0); + } + + public function test_monthly_reminder_command_sends_to_email(): void + { + DB::table('users')->insert([ + 'id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'parent@example.com', + 'status' => 'Active', + ]); + + $service = Mockery::mock(PaymentNotificationService::class); + $service->shouldReceive('send')->once()->andReturn([ + 'sent' => 1, + 'skipped' => 0, + 'failed' => 0, + 'details' => [], + ]); + + $this->app->instance(PaymentNotificationService::class, $service); + + $this->artisan('payments:monthly-reminder', ['--email' => 'parent@example.com'])->assertExitCode(0); + } + + public function test_send_test_payment_notification_command_runs(): void + { + $service = Mockery::mock(PaymentTestNotificationService::class); + $service->shouldReceive('send')->once()->andReturn(['ok' => true]); + + $this->app->instance(PaymentTestNotificationService::class, $service); + + $this->artisan('payments:send-test', ['--email' => 'parent@example.com'])->assertExitCode(0); + } + + public function test_sync_paypal_payments_command_runs(): void + { + $service = Mockery::mock(PaypalPaymentSyncService::class); + $service->shouldReceive('sync')->once()->andReturn([ + 'mode' => 'DRY-RUN', + 'processed' => 0, + 'failed' => [], + ]); + + $this->app->instance(PaypalPaymentSyncService::class, $service); + + $this->artisan('payments:sync-paypal', ['--dry-run' => true])->assertExitCode(0); + } +} diff --git a/tests/Feature/Console/SystemCommandsTest.php b/tests/Feature/Console/SystemCommandsTest.php new file mode 100644 index 00000000..9f8b251a --- /dev/null +++ b/tests/Feature/Console/SystemCommandsTest.php @@ -0,0 +1,61 @@ +shouldReceive('cleanupExpired')->once()->andReturn(2); + + $this->app->instance(NotificationCleanupService::class, $service); + + $this->artisan('notifications:cleanup')->assertExitCode(0); + } + + public function test_cleanup_password_resets_command_runs(): void + { + $service = Mockery::mock(PasswordResetCleanupService::class); + $service->shouldReceive('cleanup')->once()->andReturn(1); + + $this->app->instance(PasswordResetCleanupService::class, $service); + + $this->artisan('cleanup:password-resets')->assertExitCode(0); + } + + public function test_config_update_command_runs(): void + { + $service = Mockery::mock(ConfigUpdateService::class); + $service->shouldReceive('availableTasks')->andReturn(['enable_attendance_on']); + $service->shouldReceive('runTask')->once()->andReturn(['ok' => true]); + + $this->app->instance(ConfigUpdateService::class, $service); + + $this->artisan('config:update', ['--task' => 'enable_attendance_on', '--tz' => 'UTC'])->assertExitCode(0); + } + + public function test_delete_inactive_users_command_runs(): void + { + $service = Mockery::mock(InactiveUserCleanupService::class); + $service->shouldReceive('cleanup')->once()->andReturn([ + 'deleted_users' => 0, + 'deleted_parents' => 0, + 'deleted_roles' => 0, + ]); + + $this->app->instance(InactiveUserCleanupService::class, $service); + + $this->artisan('users:delete-inactive-users')->assertExitCode(0); + } +} diff --git a/tests/Unit/Resources/Notifications/NotificationResourceTest.php b/tests/Unit/Resources/Notifications/NotificationResourceTest.php new file mode 100644 index 00000000..b6af9945 --- /dev/null +++ b/tests/Unit/Resources/Notifications/NotificationResourceTest.php @@ -0,0 +1,51 @@ + 1, + 'title' => 'Title', + 'message' => 'Message', + 'target_group' => 'parent', + 'delivery_channels' => ['in_app'], + 'priority' => 'normal', + 'status' => 'sent', + 'action_url' => 'https://example.test', + 'attachment_path' => '/path/file.pdf', + 'scheduled_at' => now(), + 'sent_at' => now(), + 'expires_at' => now()->addDay(), + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $payload = (new NotificationResource($notification))->toArray(request()); + + $this->assertArrayHasKey('id', $payload); + $this->assertArrayHasKey('title', $payload); + $this->assertArrayHasKey('message', $payload); + $this->assertArrayHasKey('target_group', $payload); + $this->assertArrayHasKey('delivery_channels', $payload); + $this->assertArrayHasKey('priority', $payload); + $this->assertArrayHasKey('status', $payload); + $this->assertArrayHasKey('action_url', $payload); + $this->assertArrayHasKey('attachment_path', $payload); + $this->assertArrayHasKey('scheduled_at', $payload); + $this->assertArrayHasKey('sent_at', $payload); + $this->assertArrayHasKey('expires_at', $payload); + $this->assertArrayHasKey('school_year', $payload); + $this->assertArrayHasKey('semester', $payload); + $this->assertArrayHasKey('created_at', $payload); + $this->assertArrayHasKey('updated_at', $payload); + } +} diff --git a/tests/Unit/Resources/Notifications/UserNotificationResourceTest.php b/tests/Unit/Resources/Notifications/UserNotificationResourceTest.php new file mode 100644 index 00000000..bf1d3b27 --- /dev/null +++ b/tests/Unit/Resources/Notifications/UserNotificationResourceTest.php @@ -0,0 +1,35 @@ + 10, + 'title' => 'Title', + 'message' => 'Message', + 'priority' => 'high', + 'scheduled_at' => '2025-01-01 00:00:00', + 'expires_at' => '2025-01-02 00:00:00', + 'target_group' => 'parent', + 'is_read' => 1, + ]); + + $payload = $resource->toArray(request()); + + $this->assertArrayHasKey('notification_id', $payload); + $this->assertArrayHasKey('title', $payload); + $this->assertArrayHasKey('message', $payload); + $this->assertArrayHasKey('priority', $payload); + $this->assertArrayHasKey('scheduled_at', $payload); + $this->assertArrayHasKey('expires_at', $payload); + $this->assertArrayHasKey('target_group', $payload); + $this->assertArrayHasKey('is_read', $payload); + $this->assertTrue($payload['is_read']); + } +} diff --git a/tests/Unit/Services/Administrator/AdministratorAbsenceServiceTest.php b/tests/Unit/Services/Administrator/AdministratorAbsenceServiceTest.php index d5071d3d..4522565f 100644 --- a/tests/Unit/Services/Administrator/AdministratorAbsenceServiceTest.php +++ b/tests/Unit/Services/Administrator/AdministratorAbsenceServiceTest.php @@ -24,7 +24,7 @@ class AdministratorAbsenceServiceTest extends TestCase new \App\Models\User(), new \App\Models\StaffAttendance(), new \App\Services\SemesterRangeService(new \App\Services\Semesters\SemesterConfigService()), - Mockery::mock(\App\Libraries\StaffTimeOffLinkService::class) + Mockery::mock(\App\Services\Staff\StaffTimeOffLinkService::class) ); $request = Request::create('/absences', 'POST', ['dates' => ['2025-01-05']]); diff --git a/tests/Unit/Services/Attendance/AttendanceAutoPublishJobServiceTest.php b/tests/Unit/Services/Attendance/AttendanceAutoPublishJobServiceTest.php new file mode 100644 index 00000000..429b32f6 --- /dev/null +++ b/tests/Unit/Services/Attendance/AttendanceAutoPublishJobServiceTest.php @@ -0,0 +1,68 @@ +secondSundayBackwardDate($now); + + DB::table('attendance_day')->insert([ + [ + 'class_section_id' => 1, + 'date' => '2025-03-01', + 'status' => 'submitted', + 'auto_publish_at' => '2025-03-01 09:00:00', + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + [ + 'class_section_id' => 2, + 'date' => $cutoff, + 'status' => 'submitted', + 'auto_publish_at' => null, + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + [ + 'class_section_id' => 3, + 'date' => '2025-02-20', + 'status' => 'draft', + 'auto_publish_at' => null, + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + ]); + + $result = $service->run($now); + + $this->assertSame(2, $result['published']); + $this->assertDatabaseHas('attendance_day', [ + 'class_section_id' => 1, + 'status' => 'published', + ]); + $this->assertDatabaseHas('attendance_day', [ + 'class_section_id' => 2, + 'status' => 'published', + ]); + $this->assertDatabaseHas('attendance_day', [ + 'class_section_id' => 3, + 'status' => 'draft', + ]); + } +} diff --git a/tests/Unit/Services/Attendance/AttendanceAutoPublishServiceTest.php b/tests/Unit/Services/Attendance/AttendanceAutoPublishServiceTest.php new file mode 100644 index 00000000..f026e383 --- /dev/null +++ b/tests/Unit/Services/Attendance/AttendanceAutoPublishServiceTest.php @@ -0,0 +1,29 @@ +secondSundayAfterEndOfDay('2025-02-03', 'UTC'); + + $this->assertSame('2025-02-16 23:59:59', $result); + } + + public function test_second_sunday_backward_date(): void + { + $service = new AttendanceAutoPublishService(); + $now = new DateTimeImmutable('2025-02-16 10:00:00', new \DateTimeZone('UTC')); + + $result = $service->secondSundayBackwardDate($now, 'UTC'); + + $this->assertSame('2025-02-02', $result); + } +} diff --git a/tests/Unit/Services/Attendance/AttendanceConsequenceServiceTest.php b/tests/Unit/Services/Attendance/AttendanceConsequenceServiceTest.php new file mode 100644 index 00000000..75e67763 --- /dev/null +++ b/tests/Unit/Services/Attendance/AttendanceConsequenceServiceTest.php @@ -0,0 +1,79 @@ +sendFollowUp([ + 'parent' => ['user_id' => 5], + ]); + + $this->assertFalse($result['ok']); + } + + public function test_send_follow_up_marks_tracking_and_notifies(): void + { + \Illuminate\Support\Facades\DB::table('students')->insert([ + 'id' => 1, + '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', + ]); + + AttendanceTracking::query()->create([ + 'student_id' => 1, + 'date' => now(), + 'is_reported' => 1, + 'reason' => 'ABS_2', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $email = Mockery::mock(EmailService::class); + $email->shouldReceive('send')->once()->andReturn(true); + + $notifier = Mockery::mock(UserNotificationDispatchService::class); + $notifier->shouldReceive('notifyUser')->once(); + + $service = new AttendanceConsequenceService($email, $notifier); + + $result = $service->sendFollowUp([ + 'student_id' => 1, + 'parent' => ['email' => 'parent@example.com', 'user_id' => 5], + 'student' => ['firstname' => 'A', 'lastname' => 'B'], + 'tracking_id' => 1, + 'html' => '

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']); + } +}