add notifications logic and add support of both JWT and Sanctum

This commit is contained in:
root
2026-03-11 01:20:31 -04:00
parent f6be51576c
commit 182036cc41
141 changed files with 8685 additions and 648 deletions
@@ -0,0 +1,26 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceAutoPublishJobService;
use Illuminate\Console\Command;
class AttendanceAutoPublishCommand extends Command
{
protected $signature = 'attendance:auto-publish';
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
public function handle(AttendanceAutoPublishJobService $service): int
{
$result = $service->run();
$this->info(sprintf(
'Auto-publish checked at %s (TZ: %s). Published rows: %d.',
$result['checked_at'],
$result['timezone'],
$result['published']
));
return self::SUCCESS;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Console\Commands;
use App\Services\Payments\PaymentMissedCheckService;
use Illuminate\Console\Command;
class CheckMissedPaymentsCommand extends Command
{
protected $signature = 'payments:check-missed';
protected $description = 'Checks for users who missed payments and sends reminders.';
public function handle(PaymentMissedCheckService $service): int
{
$users = $service->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;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Console\Commands;
use App\Services\Notifications\NotificationCleanupService;
use Illuminate\Console\Command;
class CleanupExpiredNotificationsCommand extends Command
{
protected $signature = 'notifications:cleanup';
protected $description = 'Deletes expired notifications from the database.';
public function handle(NotificationCleanupService $service): int
{
$count = $service->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;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Auth\PasswordResetCleanupService;
use Illuminate\Console\Command;
class CleanupPasswordResetsCommand extends Command
{
protected $signature = 'cleanup:password-resets {--days=30}';
protected $description = 'Delete password reset requests older than N days.';
public function handle(PasswordResetCleanupService $service): int
{
$days = (int) $this->option('days');
$count = $service->cleanup($days > 0 ? $days : 30);
$this->info("Deleted {$count} old password reset request(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Services\System\ConfigUpdateService;
use DateTimeZone;
use Illuminate\Console\Command;
class ConfigUpdateCommand extends Command
{
protected $signature = 'config:update {task?} {--task=} {--dry} {--force} {--tz=}';
protected $description = 'Run a configuration update task (weekly cron).';
public function handle(ConfigUpdateService $service): int
{
$task = (string) ($this->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;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Services\Users\InactiveUserCleanupService;
use Illuminate\Console\Command;
class DeleteInactiveUsersCommand extends Command
{
protected $signature = 'users:delete-inactive-users {--minutes=15}';
protected $description = 'Delete users that are inactive and created more than N minutes ago.';
public function handle(InactiveUserCleanupService $service): int
{
$minutes = (int) $this->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;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceSummaryRebuildService;
use Illuminate\Console\Command;
class RecalculateAttendanceCommand extends Command
{
protected $signature = 'attendance:recalculate-summary';
protected $description = 'Recalculates the attendance summary records from raw attendance data.';
public function handle(AttendanceSummaryRebuildService $service): int
{
$result = $service->rebuild();
$this->info('Attendance summary recalculation finished. Inserted: ' . $result['inserted']);
return self::SUCCESS;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceDailySummaryService;
use Illuminate\Console\Command;
class SendAbsenteesSummaryCommand extends Command
{
protected $signature = 'attendance:absentees-summary';
protected $description = 'Sends daily attendance summaries for absences to parents.';
public function handle(AttendanceDailySummaryService $service): int
{
$sent = $service->sendAbsenteesSummary();
$this->info("Attendance absentees summary completed. Sent: {$sent}.");
return self::SUCCESS;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceDailySummaryService;
use Illuminate\Console\Command;
class SendLatesSummaryCommand extends Command
{
protected $signature = 'attendance:lates-summary';
protected $description = 'Sends daily attendance summaries for lates to parents.';
public function handle(AttendanceDailySummaryService $service): int
{
$sent = $service->sendLatesSummary();
$this->info("Attendance lates summary completed. Sent: {$sent}.");
return self::SUCCESS;
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Payments\PaymentNotificationService;
use Illuminate\Console\Command;
class SendMonthlyPaymentNotificationsCommand extends Command
{
protected $signature = 'payments:monthly-reminder {--force} {--email=} {--type=}';
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
public function handle(PaymentNotificationService $service): int
{
$force = (bool) $this->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;
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Services\Payments\PaymentTestNotificationService;
use Illuminate\Console\Command;
class SendTestPaymentNotificationCommand extends Command
{
protected $signature = 'payments:send-test {--email=} {--type=no_payment}';
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
public function handle(PaymentTestNotificationService $service): int
{
$email = (string) $this->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;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Services\Payments\PaypalPaymentSyncService;
use Illuminate\Console\Command;
class SyncPaypalPaymentsCommand extends Command
{
protected $signature = 'payments:sync-paypal {--dry-run} {--report-only}';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments.';
public function handle(PaypalPaymentSyncService $service): int
{
$dryRun = (bool) $this->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;
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\BaseApiController;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class AuthController extends BaseApiController
{
public function login(Request $request): JsonResponse
{
$validator = Validator::make($request->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.');
}
}
@@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Api\Notifications;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Notifications\NotificationActiveRequest;
use App\Http\Requests\Notifications\NotificationDeletedRequest;
use App\Http\Requests\Notifications\NotificationIndexRequest;
use App\Http\Requests\Notifications\NotificationSendRequest;
use App\Http\Requests\Notifications\NotificationUpdateRequest;
use App\Http\Resources\Notifications\NotificationResource;
use App\Http\Resources\Notifications\UserNotificationResource;
use App\Services\Notifications\NotificationActiveService;
use App\Services\Notifications\NotificationDeletedService;
use App\Services\Notifications\NotificationManagementService;
use App\Services\Notifications\NotificationReadService;
use App\Services\Notifications\NotificationSendService;
use App\Services\Notifications\NotificationShowService;
use App\Services\Notifications\NotificationUserListService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class NotificationController extends BaseApiController
{
public function __construct(
private NotificationUserListService $listService,
private NotificationSendService $sendService,
private NotificationReadService $readService,
private NotificationActiveService $activeService,
private NotificationDeletedService $deletedService,
private NotificationManagementService $managementService,
private NotificationShowService $showService
) {
parent::__construct();
}
public function index(NotificationIndexRequest $request): JsonResponse
{
$userId = (int) (auth()->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'],
]);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PHPOpenSourceSaver\JWTAuth\Exceptions\JWTException;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
use Symfony\Component\HttpFoundation\Response;
class MultiAuth
{
public function handle(Request $request, Closure $next): Response
{
$sanctumUser = Auth::guard('sanctum')->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);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Notifications;
use App\Http\Requests\ApiFormRequest;
class NotificationActiveRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'target_group' => ['nullable', 'string', 'max:50'],
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Notifications;
use App\Http\Requests\ApiFormRequest;
class NotificationDeletedRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Notifications;
use App\Http\Requests\ApiFormRequest;
class NotificationIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->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'],
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Notifications;
use App\Http\Requests\ApiFormRequest;
class NotificationSendRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->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'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Notifications;
use App\Http\Requests\ApiFormRequest;
class NotificationUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->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'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources\Notifications;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class NotificationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => (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(),
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources\Notifications;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserNotificationResource extends JsonResource
{
public function toArray(Request $request): array
{
$scheduledAt = $this->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),
];
}
}
+11 -5
View File
@@ -2,15 +2,21 @@
namespace App\Libraries;
use App\Services\Staff\StaffTimeOffLinkService as StaffTimeOffLinkTokenService;
class StaffTimeOffLinkService
{
public function createToken(array $payload): string
public function __construct(private StaffTimeOffLinkTokenService $service = new StaffTimeOffLinkTokenService())
{
$json = json_encode($payload);
if ($json === false) {
return '';
}
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
public function createToken(array $payload): string
{
return $this->service->createToken($payload);
}
public function parseToken(?string $token): ?array
{
return $this->service->parseToken($token);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Listeners;
use App\Services\Attendance\AttendanceConsequenceService;
class AttendanceConsequenceListener
{
public function __construct(private AttendanceConsequenceService $service)
{
}
public function followUp(array $payload): void
{
$this->service->sendFollowUp($payload);
}
public function finalWarning(array $payload): void
{
$this->service->sendFinalWarning($payload);
}
public function dismissal(array $payload): void
{
$this->service->sendDismissal($payload);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Listeners;
use App\Services\Grading\BelowSixtyEmailService;
class BelowSixtyEmailListener
{
public function __construct(private BelowSixtyEmailService $service)
{
}
public function handle(array $payload): void
{
$this->service->send($payload);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Listeners;
use App\Services\School\AccountEventService;
use App\Services\School\EnrollmentEventService;
use App\Services\School\PaymentEventService;
class SchoolEventListener
{
public function __construct(
private EnrollmentEventService $enrollment,
private PaymentEventService $payments,
private AccountEventService $accounts
) {
}
public function handleAdmissionUnderReview(array $data, array $studentdata): void
{
$this->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);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Listeners;
use App\Events\WhatsappInvitesSend;
use App\Services\Whatsapp\WhatsappInviteEmailService;
class WhatsappInviteListener
{
public function __construct(private WhatsappInviteEmailService $service)
{
}
public function handle(WhatsappInvitesSend $event): void
{
$this->service->send($event->payload);
}
}
+4
View File
@@ -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) {
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Providers;
use App\Events\WhatsappInvitesSend;
use App\Listeners\AttendanceConsequenceListener;
use App\Listeners\BelowSixtyEmailListener;
use App\Listeners\SchoolEventListener;
use App\Listeners\WhatsappInviteListener;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
'attendance.follow_up' => [
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,
],
];
}
@@ -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;
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Attendance;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
class AttendanceAutoPublishJobService
{
public function __construct(private AttendanceAutoPublishService $autoPublish)
{
}
public function run(?DateTimeImmutable $now = null): array
{
$zone = $this->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(),
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Attendance;
use DateTimeImmutable;
use DateTimeZone;
class AttendanceAutoPublishService
{
public function timezone(?string $tzFromCfg = null): DateTimeZone
{
$tz = $tzFromCfg ?: (class_exists(\Config\School::class)
? (config('School')->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');
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceTracking;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
class AttendanceConsequenceService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function sendFollowUp(array $payload): array
{
return $this->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 = '<p>' . e($subject) . '</p>';
}
}
$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}.",
};
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Attendance;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
class AttendanceDailySummaryService
{
public function __construct(
private UserNotificationDispatchService $notifier,
private EmailService $emailService
) {
}
public function sendAbsenteesSummary(): int
{
$rows = $this->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 = '<p>' . e($message) . '</p>';
$this->emailService->send($email, 'Daily Attendance Update', $body, 'attendance');
}
$sent++;
}
return $sent;
}
}
@@ -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)) {
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)));
}
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;
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Services\Attendance;
use Illuminate\Support\Facades\DB;
class AttendanceSummaryRebuildService
{
public function rebuild(): array
{
DB::table('attendance_record')->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)];
}
}
@@ -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',
@@ -0,0 +1,17 @@
<?php
namespace App\Services\Auth;
use App\Models\PasswordResetRequest;
class PasswordResetCleanupService
{
public function cleanup(int $days = 30): int
{
$threshold = now()->subDays($days)->toDateTimeString();
return PasswordResetRequest::query()
->where('requested_at', '<', $threshold)
->delete();
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Services\Grading;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class BelowSixtyEmailService
{
public function __construct(private EmailService $emailService)
{
}
public function send(array $payload): array
{
$studentId = (int) ($payload['student_id'] ?? 0);
$studentName = (string) ($payload['student_name'] ?? '');
$classSection = (string) ($payload['class_section_name'] ?? '');
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$scores = is_array($payload['scores'] ?? null) ? $payload['scores'] : [];
$comment = (string) ($payload['comment'] ?? '');
if ($studentId <= 0) {
Log::warning('Below60Email: missing student_id');
return ['ok' => 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 = '<p>' . e($subject) . '</p>';
}
}
$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),
];
}
}
@@ -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
@@ -0,0 +1,34 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationActiveService
{
public function list(?string $targetGroup): array
{
$rows = Notification::getActiveNotifications($targetGroup);
$now = time();
$notifications = array_map(static function ($row) use ($now) {
$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];
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationCleanupService
{
public function cleanupExpired(): int
{
return Notification::deleteExpiredNotifications();
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationDeletedService
{
public function list(): array
{
$rows = Notification::getDeletedNotifications();
$notifications = array_map(static function ($row) {
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];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Notifications;
use Illuminate\Support\Facades\Log;
class NotificationDispatchService
{
public function sendEmail(string $to, string $subject, string $message): void
{
Log::info('Notification email queued.', [
'to' => $to,
'subject' => $subject,
]);
}
public function sendSms(string $phone, string $message): void
{
Log::info('Notification SMS queued.', [
'phone' => $phone,
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
class NotificationManagementService
{
public function update(int $notificationId, array $payload): array
{
$notification = Notification::query()->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);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Notifications;
use App\Models\UserNotification;
class NotificationReadService
{
public function markRead(int $userId, int $notificationId): bool
{
$row = UserNotification::query()
->where('user_id', $userId)
->where('notification_id', $notificationId)
->first();
if (!$row) {
return false;
}
$row->is_read = true;
$row->save();
return true;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Notifications;
use App\Models\User;
class NotificationRecipientService
{
public function getRecipients(string $targetGroup): array
{
$rows = User::getUsersByRole($targetGroup);
return is_array($rows) ? $rows : [];
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Notifications;
use App\Models\Notification;
use App\Models\UserNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NotificationSendService
{
public function __construct(
private NotificationRecipientService $recipients,
private NotificationDispatchService $dispatcher
) {
}
public function send(array $payload, int $actorId): array
{
$channels = $payload['channels'] ?? ['in_app'];
$channels = array_values(array_unique(array_map('strtolower', (array) $channels)));
if (empty($channels)) {
$channels = ['in_app'];
}
return DB::transaction(function () use ($payload, $channels, $actorId) {
$notification = Notification::query()->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,
];
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Notifications;
use App\Models\UserNotification;
class NotificationShowService
{
public function getForUser(int $userId, int $notificationId): ?array
{
$row = UserNotification::query()
->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;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Notifications;
use Illuminate\Support\Facades\Event;
class NotificationTriggerService
{
public static function trigger(string $eventType, array $payload): void
{
Event::dispatch($eventType, $payload);
}
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
{
self::trigger('customNotification', [
'user_id' => $userId,
'title' => $title,
'message' => $message,
'channels' => $channels,
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Notifications;
use App\Models\UserNotification;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class NotificationUserListService
{
public function listForUser(int $userId, array $filters): array
{
$perPage = (int) ($filters['per_page'] ?? 25);
$page = (int) ($filters['page'] ?? 1);
$sortBy = (string) ($filters['sort_by'] ?? 'created_at');
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
$allowedSorts = ['created_at', 'scheduled_at', 'priority'];
if (!in_array($sortBy, $allowedSorts, true)) {
$sortBy = 'created_at';
}
$query = UserNotification::query()
->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(),
];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Notifications;
use App\Models\Configuration;
use App\Models\Notification;
use App\Models\UserNotification;
class UserNotificationDispatchService
{
public function notifyUser(
int $userId,
string $title,
string $message,
array $channels = ['in_app'],
string $topic = 'general',
?string $targetGroup = null
): array {
if ($userId <= 0) {
return ['ok' => 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,
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Payments;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
class PaymentMissedCheckService
{
public function __construct(
private UserNotificationDispatchService $notifier,
private EmailService $emailService
) {
}
public function findUsersWithMissedPayments(): array
{
$today = now()->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 = '<p>Dear ' . e($name !== '' ? $name : 'Parent') . ',</p>'
. '<p>You have a missed payment. Please pay to avoid penalty.</p>';
$ok = $this->emailService->send($email, $subject, $body, 'finance');
$ok ? $sent++ : $failed++;
}
}
return [
'sent' => $sent,
'failed' => $failed,
];
}
}
@@ -0,0 +1,220 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\FamilyGuardian;
use App\Models\Invoice;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use DateTimeImmutable;
use DateTimeZone;
class PaymentTestNotificationService
{
public function __construct(private EmailService $emailService)
{
}
public function send(string $email, string $type = 'no_payment'): array
{
$email = trim($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => 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 = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<p>
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.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
$bodyHtml .= "<ul>"
. "<li><strong>Remaining installments:</strong> {$installmentInfo['remaining_installments']}</li>"
. "<li><strong>Suggested installment this month:</strong> {$installmentInfo['installment_due_fmt']}</li>"
. "<li><strong>Maximum installments available:</strong> {$installmentInfo['max_installments']}</li>"
. "</ul>";
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');
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\PayPalPayment;
use App\Models\Payment;
use App\Models\Student;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Support\Facades\Log;
class PaypalPaymentSyncService
{
public function __construct(private EmailService $emailService)
{
}
public function sync(bool $dryRun = false, bool $reportOnly = false): array
{
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$entries = PayPalPayment::query()
->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');
}
}
+324
View File
@@ -0,0 +1,324 @@
<?php
namespace App\Services\School;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Family;
use App\Models\FamilyGuardian;
use App\Models\FamilyStudent;
use App\Models\PromotionQueue;
use App\Models\Student;
use App\Models\User;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class AccountEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function newAccountAdded(array $userData): array
{
$subject = 'Welcome to Al Rahma Sunday School!';
$message = $this->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 '<p>' . e($fallbackTitle) . '</p>';
}
}
}
@@ -0,0 +1,418 @@
<?php
namespace App\Services\School;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
class EnrollmentEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function admissionUnderReview(array $parentData, array $students): array
{
$parentName = $this->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 '<p>' . e($fallbackTitle) . '</p>';
}
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
namespace App\Services\School;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
class PaymentEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
) {
}
public function paymentReceived(array $data, array $studentdata = []): array
{
$parentName = trim((string) ($data['firstname'] ?? '') . ' ' . (string) ($data['lastname'] ?? ''));
$fmtMoney = static fn ($v) => '$' . 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 '<p>' . e($fallbackTitle) . '</p>';
}
}
}
@@ -0,0 +1,110 @@
<?php
namespace App\Services\Staff;
class StaffTimeOffLinkService
{
private const TOKEN_CONTEXT = 'timeoff_notify';
private const DEFAULT_TTL = 1209600;
private string $secret;
private int $ttl;
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
{
$this->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, '-_', '+/')) ?: '';
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Services\System;
use App\Models\Configuration;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\Log;
class ConfigUpdateService
{
private array $tasks = [
'update_date_age_reference' => '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);
}
}
@@ -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;
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Users;
use App\Models\ParentModel;
use Illuminate\Support\Facades\DB;
class InactiveUserCleanupService
{
public function cleanup(int $minutes = 15): array
{
$cutoff = now()->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();
}
}
@@ -0,0 +1,260 @@
<?php
namespace App\Services\Whatsapp;
use App\Models\WhatsappInviteLog;
use App\Services\EmailService;
use Illuminate\Support\Facades\Log;
class WhatsappInviteEmailService
{
public function __construct(private EmailService $emailService)
{
}
public function send(array $payload): array
{
$to = array_values(array_unique(array_filter((array) ($payload['to_emails'] ?? []))));
$cc = array_values(array_unique(array_filter((array) ($payload['cc_emails'] ?? []))));
$cc = array_values(array_diff($cc, $to));
if (empty($to) && !empty($cc)) {
$to = $cc;
$cc = [];
}
if (empty($to)) {
Log::warning('WhatsappInvite: no recipients after normalization.');
return ['ok' => 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;
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Commands;
use App\Libraries\AttendanceAutoPublish as AutoPublishLib;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class AttendanceAutoPublishCommand extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:auto-publish';
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
public function run(array $params)
{
$db = db_connect();
$zone = AutoPublishLib::tz();
$now = new \DateTimeImmutable('now', $zone);
$nowStr = $now->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}.");
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\UserModel;
use App\Services\NotificationService;
class CheckMissedPayments extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:check-missed';
protected $description = 'Checks for users who missed payments and sends reminders.';
public function run(array $params)
{
$userModel = new UserModel();
// Fetch users who missed payment (you need to implement this query)
$missedUsers = $userModel->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');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\NotificationModel;
class CleanupExpiredNotifications extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'notifications:cleanup';
protected $description = 'Deletes expired notifications from the database.';
public function run(array $params)
{
$model = new NotificationModel();
// Fetch expired notifications
$expired = $model->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');
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PasswordResetRequestModel;
use CodeIgniter\I18n\Time;
class CleanupPasswordResets extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'cleanup:password-resets';
protected $description = 'Delete password reset requests older than 30 days';
public function run(array $params)
{
$model = new PasswordResetRequestModel();
$threshold = Time::now()->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
*/
+232
View File
@@ -0,0 +1,232 @@
<?php
namespace App\Commands;
use App\Models\ConfigurationModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use DateTime;
use DateTimeZone;
class ConfigUpdate extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'config:update';
protected $description = 'Run a configuration update task (weekly cron).';
protected $arguments = [];
protected $usage = 'php spark config:update [task] [--task task|-t task] [--dry] [--force] [--tz=<timezone>]';
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'] ?? '<NULL>';
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 whats persisted
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
return $ok && ($after === $value);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Events\Events;
use CodeIgniter\I18n\Time;
class DeleteInactiveUsers extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'users:delete-inactive-users';
protected $description = 'Delete users that are inactive and created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
public function run(array $params)
{
$db = \Config\Database::connect();
try {
CLI::write('Running deletion of inactive users...', 'yellow');
$cutoffTime = Time::now()->subMinutes(15)->toDateTimeString();
CLI::write("Cutoff time for deletion: $cutoffTime", 'blue');
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
// ─────────────────────────────────────────────────────
// 1Fetch 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');
// ─────────────────────────────────────────────────────
// 2Transaction: 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');
}
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
class RecalculateAttendance extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:recalculate-summary';
protected $description = 'Recalculates the attendance summary records (total absences, etc.) from the raw attendance data.';
public function run(array $params)
{
$db = Database::connect();
CLI::write('Starting attendance summary recalculation...', 'yellow');
// 1. Truncate the attendance_record table
try {
$db->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');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\AttendanceDataModel;
use App\Models\UserModel;
use App\Services\NotificationService;
class SendAbsenteesSummary extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:absentees-summary';
protected $description = 'Sends daily attendance summaries to parents.';
public function run(array $params)
{
$attendanceModel = new AttendanceDataModel();
$userModel = new UserModel();
// Fetch todays absent records (replace with your logic)
$absentRecords = $attendanceModel->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');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\AttendanceDataModel;
use App\Models\UserModel;
use App\Services\NotificationService;
class SendLatesSummary extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:lates-summary';
protected $description = 'Sends daily attendance summaries to parents.';
public function run(array $params)
{
$attendanceModel = new AttendanceDataModel();
$userModel = new UserModel();
// Fetch todays late records (replace with your logic)
$lateRecords = $attendanceModel->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');
}
}
@@ -0,0 +1,372 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\PaymentNotificationLogModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Models\FamilyGuardianModel;
use App\Services\EmailService;
use App\Services\NotificationService;
class SendMonthlyPaymentNotifications extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:monthly-reminder';
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
protected EmailService $emailService;
protected ConfigurationModel $configModel;
protected InvoiceModel $invoiceModel;
protected PaymentModel $paymentModel;
protected PaymentNotificationLogModel $logModel;
protected UserModel $userModel;
protected FamilyGuardianModel $familyGuardianModel;
public function run(array $params)
{
// Lazy init to avoid BaseCommand constructor issues during discovery
$this->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 = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<ul>
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
</ul>
<p>
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.
</p>
<p>
If you have any questions or need to arrange a different plan, please reply to this email.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
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 ?: [];
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\PaymentNotificationLogModel;
use App\Models\UserModel;
use App\Models\FamilyGuardianModel;
use App\Services\EmailService;
class SendTestPaymentNotification extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:send-test';
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
public function run(array $params)
{
$email = CLI::getOption('email');
$type = CLI::getOption('type') ?? 'no_payment';
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
CLI::write('Usage: php spark payments:send-test --email=addr@example.com [--type=no_payment|installment]', 'yellow');
return;
}
$config = new ConfigurationModel();
$user = new UserModel();
$invoice= new InvoiceModel();
$logs = new PaymentNotificationLogModel();
$fam = new FamilyGuardianModel();
$mailer = new EmailService();
$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');
$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 = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<p>
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.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
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 .= "<ul>"
. "<li><strong>Remaining installments:</strong> {$remainingInst}</li>"
. "<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>"
. "<li><strong>Maximum installments available:</strong> {$maxInst}</li>"
. "</ul>";
$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');
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PayPalPaymentModel;
use App\Models\PaymentModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\StudentModel;
use App\Models\EnrollmentModel;
class SyncPaypalPayments extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:sync-paypal';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments';
protected $configModel;
protected $semester;
protected $schoolYear;
protected $paypalModel;
protected $paymentModel;
protected $userModel;
protected $invoiceModel;
protected $studentModel;
protected $enrollmentModel;
public function __construct()
{
$this->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();
}
}
}
-360
View File
@@ -1,360 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\EventModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Controllers\View\InvoiceController;
use CodeIgniter\RESTful\ResourceController;
class EventController extends ResourceController
{
protected $eventChargesModel;
protected $studentModel;
protected $userModel;
protected $configModel;
protected $eventModel;
protected $invoiceController;
protected $schoolYear;
protected $semester;
protected $categories;
protected $enrollmentModel;
public function __construct()
{
$this->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);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Events;
class DeleteUnverifiedUser
{
public $userId;
public function __construct($userId)
{
$this->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;
}
}
View File
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$authorization = $request->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,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Config\Services;
class ApiDocsAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$authHeader = $request->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)
{
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
class AuthFilter implements FilterInterface
{
protected $db;
private const LOGIN_TTL = 43200; // 12 hours in seconds
/**
* Optional: map specific paths to role IDs and/or permission names.
* - If a path is not listed here, the menu check ALLOWS by default.
* - You can use either 'roles' (role IDs) or 'permission' (permission name) or both.
* - 'crud' defaults to 'read' if omitted.
*
* Fill these to match your system or leave empty to rely only on route args.
*/
protected array $menuRules = [
// 'nav-builder' => ['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;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\I18n\Time;
class CleanupScheduler implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$lastRun = cache('last_cleanup_run');
if ($lastRun === null || Time::now()->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');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class PermissionFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (! session()->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)
{
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class TimezoneFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// Prime the TimeService detection early in the request lifecycle
try {
service('timeService')->primeFromRequest($request);
} catch (\Throwable $e) {
// ignore; fallback detection will still work lazily
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// No-op
}
}
View File
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Models\UserRoleModel;
use App\Models\RolePermissionModel;
use App\Models\PermissionModel;
function has_permission($userId, $permissionName)
{
$userRoleModel = new UserRoleModel();
$rolePermissionModel = new RolePermissionModel();
$permissionModel = new PermissionModel();
$permission = $permissionModel->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;
}
View File
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Models\ConfigurationModel;
// app/Helpers/GlobalConfigHelper.php
if (!function_exists('getSemester')) {
function getSemester() {
$configModel = new \App\Models\ConfigurationModel();
return $configModel->getConfig('semester');
}
}
if (!function_exists('getSchoolYear')) {
function getSchoolYear() {
$configModel = new \App\Models\ConfigurationModel();
return $configModel->getConfig('school_year');
}
}
@@ -0,0 +1,60 @@
<?php
use App\Models\AttendanceCommentTemplateModel;
if (!function_exists('attendance_comment_from_score')) {
function attendance_comment_from_score($score, string $firstName = ''): ?string
{
if ($score === null || $score === '') {
return null;
}
$score = (float) $score;
$template = attendance_comment_template_for_score($score);
if ($template === null) {
return null;
}
$text = trim((string) ($template['template_text'] ?? ''));
if ($text === '') {
return null;
}
$name = trim($firstName) !== '' ? trim($firstName) : 'This student';
$lastChar = $name !== '' ? substr($name, -1) : '';
$namePossessive = ($name === 'This student')
? "This student's"
: ($lastChar === 's' || $lastChar === 'S' ? ($name . "'") : ($name . "'s"));
if (strpos($text, '{name}') !== false) {
return str_replace('{name}', $namePossessive, $text);
}
return $namePossessive . ' ' . $text;
}
}
if (!function_exists('attendance_comment_template_for_score')) {
function attendance_comment_template_for_score(float $score): ?array
{
static $templates = null;
if ($templates === null) {
try {
$model = new AttendanceCommentTemplateModel();
$templates = $model->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;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
if (!function_exists('base64url_encode')) {
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
if (!function_exists('jwt_encode')) {
/**
* Minimal HS256 JWT encoder.
*
* @param array $payload
* @param string $secret
* @param string $alg Only HS256 supported here
* @return string JWT string
*/
function jwt_encode(array $payload, string $secret, string $alg = 'HS256'): string
{
$header = ['typ' => '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;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
if (!function_exists('pbkdf2_hash')) {
function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
{
$salt = random_bytes(16); // 16 bytes = 32 hex characters
$salt_hex = bin2hex($salt);
$derived_key = hash_pbkdf2($algo, $password, $salt, $iterations, $length, true);
$derived_key_hex = bin2hex($derived_key);
return "{$algo}\${$iterations}\${$salt_hex}\${$derived_key_hex}";
}
}
if (!function_exists('pbkdf2_verify')) {
function pbkdf2_verify(string $password, string $stored_hash): bool
{
$parts = explode('$', $stored_hash);
if (count($parts) !== 4) {
return false; // Invalid format
}
[$algo, $iterations, $salt_hex, $derived_key_hex] = $parts;
if (strlen($salt_hex) % 2 !== 0 || strlen($derived_key_hex) % 2 !== 0) {
return false; // Not valid hex
}
$salt = hex2bin($salt_hex);
$derived_key = hex2bin($derived_key_hex);
$length = strlen($derived_key);
$new_derived_key = hash_pbkdf2($algo, $password, $salt, (int)$iterations, $length, true);
return hash_equals($derived_key, $new_derived_key);
}
}
@@ -0,0 +1,27 @@
<?php
use App\Models\ConfigurationModel;
if (!function_exists('selected_teacher_semester')) {
function selected_teacher_semester(): string
{
$session = session();
$selected = trim((string) ($session->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';
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
use CodeIgniter\I18n\Time;
if (! function_exists('user_timezone')) {
function user_timezone(): string
{
return service('timeService')->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);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Interfaces;
interface ScoreCalculatorInterface
{
/**
* Calculate score for a student
*
* @param int $studentId
* @param string $semester
* @param string $schoolYear
* @return array Associative array of score data to merge
* @throws \RuntimeException If calculation fails
*/
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array;
}
-39
View File
@@ -1,39 +0,0 @@
<?php
namespace App\Services;
use CodeIgniter\Events\Events;
class NotificationService
{
/**
* Trigger a notification event by type and payload.
*
* @param string $eventType The name of the event (e.g., 'userRegistered')
* @param array $payload The data to pass to the listener
* @return void
*/
public static function trigger(string $eventType, array $payload): void
{
Events::trigger($eventType, $payload);
}
/**
* Helper for sending to a specific user.
*
* @param int $userId
* @param string $title
* @param string $message
* @param array $channels
* @return void
*/
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
{
self::trigger('customNotification', [
'user_id' => $userId,
'title' => $title,
'message' => $message,
'channels' => $channels
]);
}
}
-213
View File
@@ -1,213 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Controllers\View\EmailController;
use App\Models\NotificationModel;
use App\Models\UserNotificationModel;
use App\Models\UserModel;
class NotificationsController extends BaseController
{
protected $notificationModel;
protected $userNotificationModel;
protected $userModel;
public function __construct()
{
$this->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,
];
}
}
+5 -1
View File
@@ -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 {
//
+1
View File
@@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\EventServiceProvider::class,
];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->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');
}
};
+20 -4
View File
@@ -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']);

Some files were not shown because too many files have changed in this diff Show More