add notifications logic and add support of both JWT and Sanctum
This commit is contained in:
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '-_', '+/')) ?: '';
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
*/
|
||||
@@ -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 what’s persisted
|
||||
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
|
||||
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
|
||||
|
||||
return $ok && ($after === $value);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 1 Fetch inactive users older than 15 min
|
||||
// ─────────────────────────────────────────────────────
|
||||
$users = $db->table('users')
|
||||
->select('id, firstname, lastname, email, created_at')
|
||||
->where('status', 'Inactive')
|
||||
->where('created_at <', $cutoffTime)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($users)) {
|
||||
CLI::write('No inactive users found to delete.', 'yellow');
|
||||
$this->purgeOrphanedUserRoles($db);
|
||||
return;
|
||||
}
|
||||
|
||||
CLI::write('Found ' . count($users) . ' users for deletion.', 'green');
|
||||
|
||||
// collect IDs
|
||||
$userIds = array_column($users, 'id');
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 2 Transaction: delete parents → user_roles → users
|
||||
// ─────────────────────────────────────────────────────
|
||||
$db->transStart();
|
||||
|
||||
// 2-a Delete any secondary-parent rows tied to these users
|
||||
$parentsBuilder = $db->table('parents');
|
||||
$deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete();
|
||||
CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue');
|
||||
log_message('info', "Deleted $deletedParents rows from parents table.");
|
||||
|
||||
// 2-b Delete user_roles rows
|
||||
$db->table('user_roles')->whereIn('user_id', $userIds)->delete();
|
||||
CLI::write('Deleted related user_roles rows.', 'blue');
|
||||
|
||||
// 2-c Delete users
|
||||
$db->table('users')->whereIn('id', $userIds)->delete();
|
||||
CLI::write('Deleted users: ' . implode(', ', $userIds), 'green');
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if (!$db->transStatus()) {
|
||||
CLI::write('Transaction failed; rolled back.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
// Purge any stray user_role rows left behind (defensive)
|
||||
$this->purgeOrphanedUserRoles($db);
|
||||
|
||||
$msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows.";
|
||||
CLI::write($msg, 'green');
|
||||
log_message('info', $msg);
|
||||
} catch (\Throwable $e) {
|
||||
CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red');
|
||||
log_message('error', 'Error deleting inactive users: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user_roles rows that point to non-existent users.
|
||||
*/
|
||||
private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void
|
||||
{
|
||||
$orphaned = $db->table('user_roles')
|
||||
->whereNotIn('user_id', function ($q) use ($db) {
|
||||
$q->select('id')->from('users');
|
||||
})
|
||||
->delete();
|
||||
|
||||
if ($orphaned) {
|
||||
CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green');
|
||||
log_message('info', "Deleted $orphaned orphaned user_roles rows.");
|
||||
} else {
|
||||
CLI::write('No orphaned user_roles rows found.', 'yellow');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 today’s 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');
|
||||
}
|
||||
}
|
||||
@@ -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 today’s 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user