From 3e6c57708529a2f9771a721161439ed5bb6ce0e5 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Mar 2026 01:38:20 -0400 Subject: [PATCH] add services logic --- .../Commands/DeleteUnverifiedUsersCommand.php | 44 +++ app/Events/DeleteUnverifiedUser.php | 10 + .../DeleteUnverifiedUserListener.php | 18 + app/Providers/EventServiceProvider.php | 5 + .../Attendance/AttendanceCommentService.php} | 55 ++- app/Services/Auth/PermissionCheckService.php | 26 ++ .../School/SemesterSelectionService.php | 33 ++ app/Services/Security/Pbkdf2Hasher.php | 41 ++ app/Services/System/GlobalConfigService.php | 18 + app/Services/System/TimeService.php | 85 ++++ .../Users/DeleteUnverifiedUserService.php | 50 +++ .../Commands/AttendanceAutoPublishCommand.php | 46 --- app/old/Commands/CheckMissedPayments.php | 35 -- .../Commands/CleanupExpiredNotifications.php | 38 -- app/old/Commands/CleanupPasswordResets.php | 32 -- app/old/Commands/ConfigUpdate.php | 232 ----------- app/old/Commands/DeleteInactiveUsers.php | 105 ----- app/old/Commands/RecalculateAttendance.php | 93 ----- app/old/Commands/SendAbsenteesSummary.php | 42 -- app/old/Commands/SendLatesSummary.php | 42 -- .../SendMonthlyPaymentNotifications.php | 372 ------------------ .../Commands/SendTestPaymentNotification.php | 150 ------- app/old/Commands/SyncPaypalPayments.php | 232 ----------- app/old/Events/DeleteUnverifiedUser.php | 46 --- app/old/Helpers/.gitkeep | 0 app/old/Helpers/AuthHelper.php | 28 -- app/old/Helpers/DocumentHelper.php | 0 app/old/Helpers/GlobalConfigHelper.php | 18 - app/old/Helpers/jwt_helper.php | 86 ---- app/old/Helpers/pbkdf2_helper.php | 39 -- app/old/Helpers/semester_selection_helper.php | 27 -- app/old/Helpers/time_helper.php | 75 ---- .../Interfaces/ScoreCalculatorInterface.php | 17 - routes/console.php | 3 + .../DeleteUnverifiedUsersCommandTest.php | 51 +++ .../DeleteUnverifiedUserListenerTest.php | 23 ++ .../AttendanceCommentServiceTest.php | 51 +++ .../Auth/PermissionCheckServiceTest.php | 42 ++ .../School/SemesterSelectionServiceTest.php | 36 ++ .../Scores/AttendanceCalculatorTest.php | 40 ++ .../Scores/HomeworkCalculatorTest.php | 55 +++ .../Services/Scores/ProjectCalculatorTest.php | 55 +++ .../Services/Scores/QuizCalculatorTest.php | 64 +++ .../Services/Security/Pbkdf2HasherTest.php | 18 + .../System/GlobalConfigServiceTest.php | 26 ++ .../Unit/Services/System/TimeServiceTest.php | 18 + .../Users/DeleteUnverifiedUserServiceTest.php | 68 ++++ 47 files changed, 918 insertions(+), 1772 deletions(-) create mode 100644 app/Console/Commands/DeleteUnverifiedUsersCommand.php create mode 100644 app/Events/DeleteUnverifiedUser.php create mode 100644 app/Listeners/DeleteUnverifiedUserListener.php rename app/{old/Helpers/attendance_comment_helper.php => Services/Attendance/AttendanceCommentService.php} (55%) create mode 100644 app/Services/Auth/PermissionCheckService.php create mode 100644 app/Services/School/SemesterSelectionService.php create mode 100644 app/Services/Security/Pbkdf2Hasher.php create mode 100644 app/Services/System/GlobalConfigService.php create mode 100644 app/Services/System/TimeService.php create mode 100644 app/Services/Users/DeleteUnverifiedUserService.php delete mode 100644 app/old/Commands/AttendanceAutoPublishCommand.php delete mode 100644 app/old/Commands/CheckMissedPayments.php delete mode 100644 app/old/Commands/CleanupExpiredNotifications.php delete mode 100644 app/old/Commands/CleanupPasswordResets.php delete mode 100644 app/old/Commands/ConfigUpdate.php delete mode 100644 app/old/Commands/DeleteInactiveUsers.php delete mode 100644 app/old/Commands/RecalculateAttendance.php delete mode 100644 app/old/Commands/SendAbsenteesSummary.php delete mode 100644 app/old/Commands/SendLatesSummary.php delete mode 100644 app/old/Commands/SendMonthlyPaymentNotifications.php delete mode 100644 app/old/Commands/SendTestPaymentNotification.php delete mode 100644 app/old/Commands/SyncPaypalPayments.php delete mode 100644 app/old/Events/DeleteUnverifiedUser.php delete mode 100644 app/old/Helpers/.gitkeep delete mode 100644 app/old/Helpers/AuthHelper.php delete mode 100644 app/old/Helpers/DocumentHelper.php delete mode 100644 app/old/Helpers/GlobalConfigHelper.php delete mode 100644 app/old/Helpers/jwt_helper.php delete mode 100644 app/old/Helpers/pbkdf2_helper.php delete mode 100644 app/old/Helpers/semester_selection_helper.php delete mode 100644 app/old/Helpers/time_helper.php delete mode 100644 app/old/Interfaces/ScoreCalculatorInterface.php create mode 100644 tests/Feature/Console/DeleteUnverifiedUsersCommandTest.php create mode 100644 tests/Unit/Listeners/DeleteUnverifiedUserListenerTest.php create mode 100644 tests/Unit/Services/Attendance/AttendanceCommentServiceTest.php create mode 100644 tests/Unit/Services/Auth/PermissionCheckServiceTest.php create mode 100644 tests/Unit/Services/School/SemesterSelectionServiceTest.php create mode 100644 tests/Unit/Services/Scores/AttendanceCalculatorTest.php create mode 100644 tests/Unit/Services/Scores/HomeworkCalculatorTest.php create mode 100644 tests/Unit/Services/Scores/ProjectCalculatorTest.php create mode 100644 tests/Unit/Services/Scores/QuizCalculatorTest.php create mode 100644 tests/Unit/Services/Security/Pbkdf2HasherTest.php create mode 100644 tests/Unit/Services/System/GlobalConfigServiceTest.php create mode 100644 tests/Unit/Services/System/TimeServiceTest.php create mode 100644 tests/Unit/Services/Users/DeleteUnverifiedUserServiceTest.php diff --git a/app/Console/Commands/DeleteUnverifiedUsersCommand.php b/app/Console/Commands/DeleteUnverifiedUsersCommand.php new file mode 100644 index 00000000..bb8a58da --- /dev/null +++ b/app/Console/Commands/DeleteUnverifiedUsersCommand.php @@ -0,0 +1,44 @@ +option('timeout'); + if ($timeout <= 0) { + $timeout = 86400; + } + + $cutoff = now()->subSeconds($timeout); + + $users = User::query() + ->where('is_verified', 0) + ->where(function ($q) use ($cutoff) { + $q->where('updated_at', '<=', $cutoff) + ->orWhere(function ($sub) use ($cutoff) { + $sub->whereNull('updated_at')->where('created_at', '<=', $cutoff); + }); + }) + ->get(['id']); + + $count = 0; + foreach ($users as $user) { + Event::dispatch(new DeleteUnverifiedUser((int) $user->id)); + $count++; + } + + $this->info("Dispatched delete events for {$count} unverified user(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Events/DeleteUnverifiedUser.php b/app/Events/DeleteUnverifiedUser.php new file mode 100644 index 00000000..b15e9c7c --- /dev/null +++ b/app/Events/DeleteUnverifiedUser.php @@ -0,0 +1,10 @@ +service->deleteAfterTimeout($event->userId); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index fa46a5cd..a7435f14 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -3,8 +3,10 @@ namespace App\Providers; use App\Events\WhatsappInvitesSend; +use App\Events\DeleteUnverifiedUser; use App\Listeners\AttendanceConsequenceListener; use App\Listeners\BelowSixtyEmailListener; +use App\Listeners\DeleteUnverifiedUserListener; use App\Listeners\SchoolEventListener; use App\Listeners\WhatsappInviteListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -102,6 +104,9 @@ class EventServiceProvider extends ServiceProvider 'customNotification' => [ SchoolEventListener::class . '@handleCustomNotification', ], + DeleteUnverifiedUser::class => [ + DeleteUnverifiedUserListener::class, + ], WhatsappInvitesSend::class => [ WhatsappInviteListener::class, ], diff --git a/app/old/Helpers/attendance_comment_helper.php b/app/Services/Attendance/AttendanceCommentService.php similarity index 55% rename from app/old/Helpers/attendance_comment_helper.php rename to app/Services/Attendance/AttendanceCommentService.php index 5ea47e7c..87045929 100644 --- a/app/old/Helpers/attendance_comment_helper.php +++ b/app/Services/Attendance/AttendanceCommentService.php @@ -1,16 +1,21 @@ templateForScore($score); if ($template === null) { return null; } @@ -25,31 +30,22 @@ if (!function_exists('attendance_comment_from_score')) { $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 + public function templateForScore(float $score): ?array { - static $templates = null; - - if ($templates === null) { - try { - $model = new AttendanceCommentTemplateModel(); - $templates = $model->getActiveTemplates(); - } catch (\Throwable $e) { - $templates = []; - } - } + $templates = $this->loadTemplates(); 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; } @@ -57,4 +53,29 @@ if (!function_exists('attendance_comment_template_for_score')) { return null; } + + public function clearCache(): void + { + $this->templates = null; + } + + private function loadTemplates(): array + { + if ($this->templates !== null) { + return $this->templates; + } + + try { + $this->templates = AttendanceCommentTemplate::query() + ->where('is_active', 1) + ->orderByDesc('min_score') + ->get() + ->map(fn ($row) => $row->toArray()) + ->all(); + } catch (\Throwable $e) { + $this->templates = []; + } + + return $this->templates; + } } diff --git a/app/Services/Auth/PermissionCheckService.php b/app/Services/Auth/PermissionCheckService.php new file mode 100644 index 00000000..767e52ac --- /dev/null +++ b/app/Services/Auth/PermissionCheckService.php @@ -0,0 +1,26 @@ +where('name', $permissionName) + ->value('id'); + + if (!$permissionId) { + return false; + } + + return DB::table('user_roles') + ->join('role_permissions', 'role_permissions.role_id', '=', 'user_roles.role_id') + ->where('user_roles.user_id', $userId) + ->whereNull('user_roles.deleted_at') + ->where('role_permissions.permission_id', $permissionId) + ->exists(); + } +} diff --git a/app/Services/School/SemesterSelectionService.php b/app/Services/School/SemesterSelectionService.php new file mode 100644 index 00000000..77ead4c6 --- /dev/null +++ b/app/Services/School/SemesterSelectionService.php @@ -0,0 +1,33 @@ +get('teacher_scores_selected_semester') ?? '')); + if ($selected !== '') { + return $this->normalize($selected); + } + + $fallback = trim((string) (session()->get('semester') ?? '')); + if ($fallback !== '') { + return $this->normalize($fallback); + } + + $configSemester = trim((string) (Configuration::getConfig('semester') ?? '')); + if ($configSemester !== '') { + return $this->normalize($configSemester); + } + + return 'Fall'; + } + + private function normalize(string $value): string + { + return ucfirst(strtolower($value)); + } +} diff --git a/app/Services/Security/Pbkdf2Hasher.php b/app/Services/Security/Pbkdf2Hasher.php new file mode 100644 index 00000000..9249bdc1 --- /dev/null +++ b/app/Services/Security/Pbkdf2Hasher.php @@ -0,0 +1,41 @@ +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'); + } + + public function serverTimezone(): string + { + return (string) (config('app.timezone') ?: 'UTC'); + } + + public function nowUtc(): CarbonImmutable + { + return CarbonImmutable::now('UTC'); + } + + public function nowLocal(?string $tz = null): CarbonImmutable + { + return CarbonImmutable::now($tz ?: $this->userTimezone()); + } + + public function toUtc($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string + { + $dt = $this->parse($value, $fromTz); + if (!$dt) { + return null; + } + + return $dt->setTimezone('UTC')->format($format); + } + + public function toLocal($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string + { + $dt = $this->parse($value, $sourceTz); + if (!$dt) { + return null; + } + + return $dt->setTimezone($targetTz ?: $this->userTimezone())->format($format); + } + + public function formatLocal($value, string $format = 'Y-m-d', ?string $sourceTz = null): string + { + $dt = $this->parse($value, $sourceTz); + if (!$dt) { + return ''; + } + + return $dt->setTimezone($this->userTimezone())->format($format); + } + + private function parse($value, ?string $tz = null): ?CarbonImmutable + { + if ($value instanceof DateTimeInterface) { + return CarbonImmutable::instance($value); + } + + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + try { + return CarbonImmutable::parse($raw, $tz ?: $this->serverTimezone()); + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/app/Services/Users/DeleteUnverifiedUserService.php b/app/Services/Users/DeleteUnverifiedUserService.php new file mode 100644 index 00000000..6070640f --- /dev/null +++ b/app/Services/Users/DeleteUnverifiedUserService.php @@ -0,0 +1,50 @@ +find($userId); + if (!$user) { + return ['ok' => false, 'reason' => 'not_found']; + } + + if ((int) ($user->is_verified ?? 0) === 1) { + return ['ok' => false, 'reason' => 'verified']; + } + + $lastActivity = $user->updated_at ?? $user->created_at; + if (!$lastActivity) { + return ['ok' => false, 'reason' => 'no_timestamp']; + } + + $cutoff = now()->subSeconds($timeoutSeconds); + if ($lastActivity > $cutoff) { + return ['ok' => false, 'reason' => 'not_expired']; + } + + $userPayload = $user->toArray(); + + DB::transaction(function () use ($userId, $user): void { + DB::table('user_roles')->where('user_id', $userId)->delete(); + $user->delete(); + }); + + $this->accountEvents->deleteUnverifiedUser($userPayload); + + Log::info('Deleted unverified user after timeout.', ['user_id' => $userId]); + + return ['ok' => true, 'deleted' => true, 'user_id' => $userId]; + } +} diff --git a/app/old/Commands/AttendanceAutoPublishCommand.php b/app/old/Commands/AttendanceAutoPublishCommand.php deleted file mode 100644 index 9f9ae92a..00000000 --- a/app/old/Commands/AttendanceAutoPublishCommand.php +++ /dev/null @@ -1,46 +0,0 @@ -format('Y-m-d H:i:s'); - - $cutoffDate = AutoPublishLib::secondSundayBackwardDate($now); - - $builder = $db->table('attendance_day'); - $builder->where('status', 'submitted') - ->groupStart() - ->where('auto_publish_at <=', $nowStr) - ->orGroupStart() - ->where('auto_publish_at IS NULL', null, false) - ->where('date <=', $cutoffDate) - ->groupEnd() - ->groupEnd(); - - $count = $builder->countAllResults(false); // keep the WHEREs - - if ($count > 0) { - $builder->update([ - 'status' => 'published', - 'published_by' => 0, // system - 'published_at' => $nowStr, - 'updated_at' => $nowStr, - ]); - } - - CLI::write("Auto-publish checked at {$nowStr} (TZ: {$zone->getName()}). Published rows: {$count}."); - } -} diff --git a/app/old/Commands/CheckMissedPayments.php b/app/old/Commands/CheckMissedPayments.php deleted file mode 100644 index a1280b9c..00000000 --- a/app/old/Commands/CheckMissedPayments.php +++ /dev/null @@ -1,35 +0,0 @@ -getUsersWithMissedPayments(); - - foreach ($missedUsers as $user) { - NotificationService::toUser( - $user['id'], - 'Payment Missed', - 'You have a missed payment. Please pay to avoid penalty.', - ['in_app', 'email', 'sms'] - ); - CLI::write("Reminder sent to {$user['email']}", 'yellow'); - } - - CLI::write("Finished checking missed payments.", 'green'); - } -} \ No newline at end of file diff --git a/app/old/Commands/CleanupExpiredNotifications.php b/app/old/Commands/CleanupExpiredNotifications.php deleted file mode 100644 index 0e2188d6..00000000 --- a/app/old/Commands/CleanupExpiredNotifications.php +++ /dev/null @@ -1,38 +0,0 @@ -where('expires_at IS NOT NULL') - ->where('expires_at < NOW()') - ->findAll(); - - if (empty($expired)) { - CLI::write("ℹ No expired notifications found to soft delete.", 'yellow'); - return; - } - - $count = 0; - foreach ($expired as $note) { - $model->delete($note['id']); // Soft delete - $count++; - } - - CLI::write("✅ Soft-deleted {$count} expired notifications.", 'green'); - } - -} diff --git a/app/old/Commands/CleanupPasswordResets.php b/app/old/Commands/CleanupPasswordResets.php deleted file mode 100644 index c254a368..00000000 --- a/app/old/Commands/CleanupPasswordResets.php +++ /dev/null @@ -1,32 +0,0 @@ -subDays(30)->toDateTimeString(); - - $count = $model->where('requested_at <', $threshold)->delete(); - - CLI::write("Deleted $count old password reset request(s).", 'green'); - } -} - - -/* -Add this cron job to run every 24hrs -0 0 * * * /usr/bin/php /path/to/project/public/index.php cleanup:password-resets >> /path/to/project/writable/logs/cleanup.log 2>&1 - -*/ \ No newline at end of file diff --git a/app/old/Commands/ConfigUpdate.php b/app/old/Commands/ConfigUpdate.php deleted file mode 100644 index deb55986..00000000 --- a/app/old/Commands/ConfigUpdate.php +++ /dev/null @@ -1,232 +0,0 @@ -]'; - protected $options = [ - 'task' => 'Task name (or pass as first positional arg)', - 't' => 'Short form of --task', - 'dry' => 'Dry run (no DB writes)', - 'force' => 'Ignore lock and run anyway', - 'tz' => 'Timezone (default: configured school timezone)', - ]; - - - /** @var ConfigurationModel */ - protected $configModel; - - /** Map of available task names -> handler methods. */ - protected array $tasks = [ - 'update_date_age_reference' => 'taskUpdateDateAgeReference', - 'enable_attendance_on' => 'taskEnableAttendanceOn', - 'enable_attendance_off' => 'taskEnableAttendanceOff', - 'set_semester_spring' => 'taskSetSemesterSpring', - 'set_semester_fall' => 'taskSetSemesterFall', - ]; - - - /** - * Set enable_attendance = 1 - */ - protected function taskEnableAttendanceOn(bool $dry, DateTimeZone $tz): bool - { - return $this->setConfig('enable_attendance', '1', $dry); - } - - /** - * Set enable_attendance = 0 - */ - protected function taskEnableAttendanceOff(bool $dry, DateTimeZone $tz): bool - { - return $this->setConfig('enable_attendance', '0', $dry); - } - - protected function taskUpdateDateAgeReference(bool $dry, DateTimeZone $tz): bool - { - $now = new DateTime('now', $tz); - $isJune1 = ($now->format('n') === '6' && $now->format('j') === '1'); - $forced = (CLI::getOption('force') !== null); - - if (!$isJune1 && !$forced) { - CLI::write( - "Today is {$now->format('Y-m-d')} (not June 1) — skipping. Use --force to override.", - 'yellow' - ); - return true; // no-op, not an error - } - - $year = (int) $now->format('Y'); - $value = sprintf('%04d-12-31', $year); - - CLI::write("Set date_age_reference = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray'); - if ($dry) return true; - - // Use your model method - $ok = (bool) $this->configModel->setConfigValueByKey('date_age_reference', $value); - - if ($ok) { - CLI::write("date_age_reference updated to {$value}", 'green'); - } else { - CLI::error("Failed to update date_age_reference"); - } - return $ok; - } - - protected function taskSetSemesterSpring(bool $dry, DateTimeZone $tz): bool - { - $now = new DateTime('now', $tz); - $isFeb1 = ($now->format('n') === '2' && $now->format('j') === '1'); - $forced = (CLI::getOption('force') !== null); - - if (!$isFeb1 && !$forced) { - CLI::write( - "Today is {$now->format('Y-m-d')} (not Feb 1) — skipping. Use --force to override.", - 'yellow' - ); - return true; // no-op is success - } - - CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray'); - if ($dry) return true; - - return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring'); - } - - protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool - { - $now = new DateTime('now', $tz); - $isJun1 = ($now->format('n') === '6' && $now->format('j') === '1'); - $forced = (CLI::getOption('force') !== null); - - if (!$isJun1 && !$forced) { - CLI::write( - "Today is {$now->format('Y-m-d')} (not Jun 1) — skipping. Use --force to override.", - 'yellow' - ); - return true; - } - - CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray'); - if ($dry) return true; - - return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall'); - } - - public function run(array $params) - { - $this->configModel = model(ConfigurationModel::class); - - $tz = $this->getOptionString('tz', $params) ?? ((string)(config('School')->attendance['timezone'] ?? 'UTC')); - $task = $this->getOptionString('task', $params, 't'); - - // Fallback: first positional arg (php spark config:update enable_attendance_on) - if (!$task && !empty($params) && strpos($params[0], '-') !== 0) { - $task = trim($params[0]); - } - - $dry = $this->hasFlag('dry', $params); - $force = $this->hasFlag('force', $params); - $dtz = new DateTimeZone($tz); - - if ($task === '' || !isset($this->tasks[$task])) { - CLI::error('Invalid or missing --task. Available: ' . implode(', ', array_keys($this->tasks))); - return; - } - - // Per-task lock - $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "ci4_config_update_{$task}.lock"; - $fp = @fopen($lockFile, 'c+'); - if (!$fp) { - CLI::error("Unable to open lock file: $lockFile"); - return; - } - if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) { - CLI::error("Task '$task' is already running (lock held). Use --force to override."); - fclose($fp); - return; - } - - try { - $method = $this->tasks[$task]; - CLI::write("Running task: {$task}" . ($dry ? ' [DRY RUN]' : ''), 'yellow'); - $ok = $this->{$method}($dry, $dtz); - if ($ok === true) { - CLI::write("Task '{$task}' finished successfully.", 'green'); - } else { - CLI::error("Task '{$task}' finished with warnings or no changes."); - } - } catch (\Throwable $e) { - CLI::error("Task '{$task}' failed: " . $e->getMessage()); - } finally { - try { - flock($fp, LOCK_UN); - fclose($fp); - @unlink($lockFile); - } catch (\Throwable $e) { - } - } - } - - /** Accepts --name=value, --name value, -s value, or scans $params. */ - private function getOptionString(string $name, array $params, ?string $short = null): ?string - { - $v = CLI::getOption($name); - if (is_string($v) && $v !== '') return trim($v); - if ($short) { - $v = CLI::getOption($short); - if (is_string($v) && $v !== '') return trim($v); - } - foreach ($params as $i => $p) { - if (strpos($p, "--{$name}=") === 0) return trim(substr($p, strlen($name) + 3)); - if ($short && strpos($p, "-{$short}=") === 0) return trim(substr($p, strlen($short) + 2)); - if ($p === "--{$name}" || ($short && $p === "-{$short}")) { - return $params[$i + 1] ?? null; - } - } - return null; - } - - /** True if flag present as --name or -s (no value needed). */ - private function hasFlag(string $name, array $params, ?string $short = null): bool - { - if (CLI::getOption($name) !== null) return true; - if ($short && CLI::getOption($short) !== null) return true; - foreach ($params as $p) { - if ($p === "--{$name}" || ($short && $p === "-{$short}")) return true; - } - return false; - } - - - /* ------------------------- Helpers ------------------------- */ - protected function setConfig(string $key, string $value, bool $dry): bool - { - // show current value - $current = $this->configModel->where('config_key', $key)->first()['config_value'] ?? ''; - CLI::write("{$key}: current={$current}", 'light_gray'); - - CLI::write("Set {$key} = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray'); - if ($dry) return true; - - // ✅ use your model function - $ok = (bool) $this->configModel->setConfigValueByKey($key, $value); - - // read-back to verify what’s persisted - $after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? ''; - CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red'); - - return $ok && ($after === $value); - } -} diff --git a/app/old/Commands/DeleteInactiveUsers.php b/app/old/Commands/DeleteInactiveUsers.php deleted file mode 100644 index b22812c9..00000000 --- a/app/old/Commands/DeleteInactiveUsers.php +++ /dev/null @@ -1,105 +0,0 @@ -subMinutes(15)->toDateTimeString(); - CLI::write("Cutoff time for deletion: $cutoffTime", 'blue'); - log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime); - - // ───────────────────────────────────────────────────── - // 1 Fetch inactive users older than 15 min - // ───────────────────────────────────────────────────── - $users = $db->table('users') - ->select('id, firstname, lastname, email, created_at') - ->where('status', 'Inactive') - ->where('created_at <', $cutoffTime) - ->get() - ->getResultArray(); - - if (empty($users)) { - CLI::write('No inactive users found to delete.', 'yellow'); - $this->purgeOrphanedUserRoles($db); - return; - } - - CLI::write('Found ' . count($users) . ' users for deletion.', 'green'); - - // collect IDs - $userIds = array_column($users, 'id'); - - // ───────────────────────────────────────────────────── - // 2 Transaction: delete parents → user_roles → users - // ───────────────────────────────────────────────────── - $db->transStart(); - - // 2-a Delete any secondary-parent rows tied to these users - $parentsBuilder = $db->table('parents'); - $deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete(); - CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue'); - log_message('info', "Deleted $deletedParents rows from parents table."); - - // 2-b Delete user_roles rows - $db->table('user_roles')->whereIn('user_id', $userIds)->delete(); - CLI::write('Deleted related user_roles rows.', 'blue'); - - // 2-c Delete users - $db->table('users')->whereIn('id', $userIds)->delete(); - CLI::write('Deleted users: ' . implode(', ', $userIds), 'green'); - - $db->transComplete(); - - if (!$db->transStatus()) { - CLI::write('Transaction failed; rolled back.', 'red'); - return; - } - - // Purge any stray user_role rows left behind (defensive) - $this->purgeOrphanedUserRoles($db); - - $msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows."; - CLI::write($msg, 'green'); - log_message('info', $msg); - } catch (\Throwable $e) { - CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red'); - log_message('error', 'Error deleting inactive users: ' . $e->getMessage()); - } - } - - /** - * Remove user_roles rows that point to non-existent users. - */ - private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void - { - $orphaned = $db->table('user_roles') - ->whereNotIn('user_id', function ($q) use ($db) { - $q->select('id')->from('users'); - }) - ->delete(); - - if ($orphaned) { - CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green'); - log_message('info', "Deleted $orphaned orphaned user_roles rows."); - } else { - CLI::write('No orphaned user_roles rows found.', 'yellow'); - } - } - - -} \ No newline at end of file diff --git a/app/old/Commands/RecalculateAttendance.php b/app/old/Commands/RecalculateAttendance.php deleted file mode 100644 index d4944b62..00000000 --- a/app/old/Commands/RecalculateAttendance.php +++ /dev/null @@ -1,93 +0,0 @@ -table('attendance_record')->truncate(); - CLI::write('Successfully truncated attendance_record table.', 'green'); - } catch (\Throwable $e) { - CLI::error('Failed to truncate attendance_record table: ' . $e->getMessage()); - return; - } - - // 2. Get all attendance data - $attendanceData = $db->table('attendance_data') - ->orderBy('student_id', 'ASC') - ->orderBy('school_year', 'ASC') - ->orderBy('semester', 'ASC') - ->get()->getResultArray(); - - if (empty($attendanceData)) { - CLI::write('No attendance data found to process.', 'yellow'); - return; - } - - $summary = []; - - // 3. Process the data - foreach ($attendanceData as $row) { - $studentId = $row['student_id']; - $schoolYear = $row['school_year']; - $semester = $row['semester']; - $status = strtolower($row['status']); - - $key = "{$studentId}-{$schoolYear}-{$semester}"; - - if (!isset($summary[$key])) { - $summary[$key] = [ - 'student_id' => $studentId, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'class_section_id' => $row['class_section_id'], - 'school_id' => $row['school_id'], - 'total_presence' => 0, - 'total_absence' => 0, - 'total_late' => 0, - 'total_attendance' => 0, - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]; - } - - $summary[$key]['total_attendance']++; - if ($status === 'present') { - $summary[$key]['total_presence']++; - } elseif ($status === 'absent') { - $summary[$key]['total_absence']++; - } elseif ($status === 'late') { - $summary[$key]['total_late']++; - } - } - - // 4. Insert the new summary records - if (!empty($summary)) { - $builder = $db->table('attendance_record'); - try { - $builder->insertBatch(array_values($summary)); - CLI::write('Successfully inserted ' . count($summary) . ' summary records.', 'green'); - } catch (\Throwable $e) { - CLI::error('Failed to insert summary records: ' . $e->getMessage()); - return; - } - } - - CLI::write('Attendance summary recalculation finished.', 'green'); - } -} diff --git a/app/old/Commands/SendAbsenteesSummary.php b/app/old/Commands/SendAbsenteesSummary.php deleted file mode 100644 index 361c5efd..00000000 --- a/app/old/Commands/SendAbsenteesSummary.php +++ /dev/null @@ -1,42 +0,0 @@ -getTodayAbsentees(); - - foreach ($absentRecords as $record) { - // Get parent user ID - $parent = $userModel->find($record['parent_id']); - if (!$parent) continue; - - NotificationService::toUser( - $parent['id'], - 'Daily Attendance Update', - "{$record['student_name']} was marked absent on {$record['date']}.", - ['in_app', 'email'] - ); - - CLI::write("Sent summary to parent: {$parent['email']}", 'yellow'); - } - - CLI::write("Attendance summary completed.", 'green'); - } -} \ No newline at end of file diff --git a/app/old/Commands/SendLatesSummary.php b/app/old/Commands/SendLatesSummary.php deleted file mode 100644 index c37487a6..00000000 --- a/app/old/Commands/SendLatesSummary.php +++ /dev/null @@ -1,42 +0,0 @@ -getTodayLates(); - - foreach ($lateRecords as $record) { - // Get parent user ID - $parent = $userModel->find($record['parent_id']); - if (!$parent) continue; - - NotificationService::toUser( - $parent['id'], - 'Daily Attendance Update', - "{$record['student_name']} was marked late on {$record['date']}.", - ['in_app', 'email'] - ); - - CLI::write("Sent summary to parent: {$parent['email']}", 'yellow'); - } - - CLI::write("Attendance summary completed.", 'green'); - } -} diff --git a/app/old/Commands/SendMonthlyPaymentNotifications.php b/app/old/Commands/SendMonthlyPaymentNotifications.php deleted file mode 100644 index bc6616f3..00000000 --- a/app/old/Commands/SendMonthlyPaymentNotifications.php +++ /dev/null @@ -1,372 +0,0 @@ -emailService = new EmailService(); - $this->configModel = new ConfigurationModel(); - $this->invoiceModel = new InvoiceModel(); - $this->paymentModel = new PaymentModel(); - $this->logModel = new PaymentNotificationLogModel(); - $this->userModel = new UserModel(); - $this->familyGuardianModel = new FamilyGuardianModel(); - - // Parse params: --force, --email=addr, --type=no_payment|installment - $force = false; - $targetEmail = null; - $targetType = null; - foreach ($params as $p) { - if ($p === '--force') { $force = true; continue; } - if (strpos($p, '--email=') === 0) { $targetEmail = trim(substr($p, 8)); continue; } - if (strpos($p, '--type=') === 0) { $targetType = trim(substr($p, 7)); continue; } - } - // Also support CI4 options parser - $optEmail = CLI::getOption('email'); - if ($optEmail !== null) $targetEmail = $optEmail; - $optType = CLI::getOption('type'); - if ($optType !== null) $targetType = $optType; - if (CLI::getOption('force') !== null) $force = true; - - $tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC'); - $tz = new \DateTimeZone($tzName); - $now = new \DateTime('now', $tz); - $year = (int) $now->format('Y'); - $month = (int) $now->format('n'); - - if (!$targetEmail && !$force && !$this->isFirstSaturday($now)) { - CLI::write('Not the first Saturday of the month. Use --force to override.', 'yellow'); - return; - } - - $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? $year); - - // Targeted test mode: only send to a specific email - if ($targetEmail) { - if (!filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) { - CLI::write('Invalid --email provided', 'red'); - return; - } - - $userRow = $this->userModel->where('email', $targetEmail)->first(); - $parentId = (int)($userRow['id'] ?? 0); - - $invRows = $parentId ? $this->invoiceModel->getAllInvoicesByUserIds([$parentId], $schoolYear) : []; - $totalBalance = 0.0; - $latestInvoiceId = null; - foreach ($invRows as $ir) { - $totalBalance += (float)($ir['balance'] ?? 0); - if ($latestInvoiceId === null) { - $latestInvoiceId = (int) ($ir['id'] ?? 0); - } - } - - $type = in_array($targetType, ['no_payment','installment'], true) ? $targetType : 'no_payment'; - $ccEmail = $parentId ? $this->getSecondaryGuardianEmail($parentId) : null; - - [$subject, $body] = $this->composeEmail($parentId ?: 0, $schoolYear, $type, $totalBalance, $now); - - $sentOk = $this->emailService->send($targetEmail, $subject, $body, 'finance'); - if ($sentOk && $ccEmail && strcasecmp($ccEmail, $targetEmail) !== 0) { - $this->emailService->send($ccEmail, $subject, $body, 'finance'); - } - - // Upsert log for this month - $existing = $this->logModel->where('parent_id', $parentId) - ->where('period_year', $year) - ->where('period_month', $month) - ->where('type', $type) - ->first(); - - $payload = [ - 'parent_id' => $parentId, - 'invoice_id' => $latestInvoiceId, - 'school_year' => $schoolYear, - 'period_year' => $year, - 'period_month' => $month, - 'type' => $type, - 'to_email' => $targetEmail, - 'cc_email' => $ccEmail, - 'head_fa_notified' => 0, - 'subject' => $subject, - 'body' => $body, - 'status' => $sentOk ? 'sent' : 'failed', - 'error_message' => $sentOk ? null : 'Email send failed (see logs)', - 'balance_snapshot' => $totalBalance, - 'sent_at' => $now->format('Y-m-d H:i:s'), - ]; - - if ($existing) { - $this->logModel->update($existing['id'], $payload); - } else { - $this->logModel->insert($payload); - } - - CLI::write(($sentOk ? 'Sent' : 'Failed') . " test reminder to {$targetEmail}", $sentOk ? 'green' : 'red'); - return; - } - - // Get all parents with invoices for the current school year - $db = \Config\Database::connect(); - $rows = $db->table('invoices') - ->select('parent_id') - ->where('school_year', $schoolYear) - ->groupBy('parent_id') - ->get() - ->getResultArray(); - - $parentIds = array_values(array_unique(array_map(static fn($r) => (int) $r['parent_id'], $rows))); - if (empty($parentIds)) { - CLI::write('No invoices found for current school year. Nothing to do.', 'yellow'); - return; - } - - $sentCount = 0; - foreach ($parentIds as $parentId) { - // Compute total balance across invoices for this year - $invRows = $this->invoiceModel->getAllInvoicesByUserIds([$parentId], $schoolYear); - $totalBalance = 0.0; - $latestInvoiceId = null; - foreach ($invRows as $ir) { - $totalBalance += (float)($ir['balance'] ?? 0); - if ($latestInvoiceId === null) { - $latestInvoiceId = (int) ($ir['id'] ?? 0); - } - } - - if ($totalBalance <= 0.0) { - continue; // up to date - } - - // Determine if parent has any payments this school year - $hasPayments = $db->table('payments') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->countAllResults() > 0; - - $type = $hasPayments ? 'installment' : 'no_payment'; - - // Idempotency guard: skip if already sent this period for this type - if ($this->logModel->existsForPeriod($parentId, $year, $month, $type)) { - continue; - } - - // Recipient emails: primary guardian (current parent), CC second guardian in same family - $toEmail = $this->getUserEmail($parentId); - $ccEmail = $this->getSecondaryGuardianEmail($parentId); - - if (!$toEmail) { - // Log failed attempt due to missing email and continue - $this->logModel->insert([ - 'parent_id' => $parentId, - 'invoice_id' => $latestInvoiceId, - 'school_year' => $schoolYear, - 'period_year' => $year, - 'period_month' => $month, - 'type' => $type, - 'to_email' => null, - 'cc_email' => $ccEmail, - 'subject' => 'Monthly Tuition Reminder', - 'body' => null, - 'status' => 'failed', - 'error_message' => 'No primary email on file', - 'balance_snapshot' => $totalBalance, - ]); - CLI::write("Skipped parent {$parentId} due to missing email", 'yellow'); - continue; - } - - // Compose email - [$subject, $body] = $this->composeEmail($parentId, $schoolYear, $type, $totalBalance, $now); - - $sentOk = $this->emailService->send($toEmail, $subject, $body, 'finance'); - if ($sentOk && $ccEmail && strcasecmp($ccEmail, $toEmail) !== 0) { - // Send a separate copy to the secondary guardian - $this->emailService->send($ccEmail, $subject, $body, 'finance'); - } - - // Notify Head of Finance (in-app) - $headFaUsers = $this->getHeadOfFinanceUsers(); - foreach ($headFaUsers as $u) { - NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent', - sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $totalBalance), - ['in_app'] - ); - } - - $this->logModel->insert([ - 'parent_id' => $parentId, - 'invoice_id' => $latestInvoiceId, - 'school_year' => $schoolYear, - 'period_year' => $year, - 'period_month' => $month, - 'type' => $type, - 'to_email' => $toEmail, - 'cc_email' => $ccEmail, - 'head_fa_notified' => !empty($headFaUsers) ? 1 : 0, - 'subject' => $subject, - 'body' => $body, - 'status' => $sentOk ? 'sent' : 'failed', - 'error_message' => $sentOk ? null : 'Email send failed (see logs)', - 'balance_snapshot' => $totalBalance, - 'sent_at' => $now->format('Y-m-d H:i:s'), - ]); - - if ($sentOk) { - $sentCount++; - CLI::write("Reminder sent to {$toEmail}" . ($ccEmail ? ", CC {$ccEmail}" : ''), 'green'); - } else { - CLI::write("Failed to send to {$toEmail}", 'red'); - } - } - - // Summary to head of finance (in-app broadcast) - $headFaUsers = $this->getHeadOfFinanceUsers(); - foreach ($headFaUsers as $u) { - NotificationService::toUser((int)$u['id'], 'Monthly Payment Reminders Summary', - sprintf('Total reminders sent this run: %d (School Year: %s).', $sentCount, $schoolYear), - ['in_app'] - ); - } - - CLI::write("Done. Total sent: {$sentCount}", 'green'); - } - - private function isFirstSaturday(\DateTimeInterface $dt): bool - { - // Saturday = 6 (PHP: 0 Sun .. 6 Sat) - $isSaturday = ((int)$dt->format('w')) === 6; - $isFirstWeek = ((int)$dt->format('j')) <= 7; - return $isSaturday && $isFirstWeek; - } - - private function getUserEmail(int $userId): ?string - { - $u = $this->userModel->select('email')->find($userId); - $email = $u['email'] ?? null; - return $email && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; - } - - private function getSecondaryGuardianEmail(int $primaryGuardianUserId): ?string - { - // Find family of primary guardian - $row = $this->familyGuardianModel->where('user_id', $primaryGuardianUserId)->first(); - if (!$row || empty($row['family_id'])) { - return null; - } - $familyId = (int)$row['family_id']; - - $others = $this->familyGuardianModel - ->where('family_id', $familyId) - ->where('user_id !=', $primaryGuardianUserId) - ->where('receive_emails', 1) - ->findAll(); - - foreach ($others as $g) { - $email = $this->getUserEmail((int)$g['user_id']); - if ($email) return $email; - } - return null; - } - - private function composeEmail(int $parentId, string $schoolYear, string $type, float $balance, \DateTimeInterface $now): array - { - $parent = $this->userModel->find($parentId) ?: []; - $parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')); - $monthYear = $now->format('F Y'); - - $subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear); - $greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,'; - $balanceFmt = '$' . number_format($balance, 2); - - if ($type === 'no_payment') { - $intro = "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."; - } else { - $intro = "This is your monthly installment reminder for {$schoolYear}."; - } - - // Compute remaining and max installments similar to Manual Payment UI - $installmentEndRaw = (string)$this->configModel->getConfig('installment_date'); - $tz = new \DateTimeZone($tzName); - $today = new \DateTimeImmutable('today', $tz); - $end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; } - $remMonths = 0; - if ($end) { - $y = (int)$end->format('Y') - (int)$today->format('Y'); - $m = (int)$end->format('n') - (int)$today->format('n'); - $remMonths = $y * 12 + $m; - if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1; - if ($remMonths < 0) $remMonths = 0; - } - $remainingInst = max(1, $remMonths); - $maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0)); - $instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2); - - $bodyHtml = << -

Monthly Tuition Reminder

-

{$greeting}

-

{$intro}

-

Current Outstanding Balance: {$balanceFmt}

-
    -
  • Remaining installments: {$remainingInst}
  • -
  • Suggested installment this month: {$instDueFmt}
  • -
  • Maximum installments available: {$maxInst}
  • -
-

- As a friendly reminder, our tuition installments are due at the beginning of each month. - You can review your invoice and payment options by logging in to your parent portal. -

-

- If you have any questions or need to arrange a different plan, please reply to this email. -

-

Thank you for your prompt attention.

-

Reminder Period: {$monthYear}

- - HTML; - - $body = view('emails/_wrap_layout', [ - 'title' => 'Monthly Tuition Reminder', - 'body_html' => $bodyHtml, - ], ['saveData' => true]); - - return [$subject, $body]; - } - - private function getHeadOfFinanceUsers(): array - { - // Try the explicit role label used in the UI first - $heads = $this->userModel->getUsersByRole('head of department (finance)'); - if (!empty($heads)) return $heads; - // fallback to accountant role if needed - $acct = $this->userModel->getUsersByRole('accountant'); - return $acct ?: []; - } -} diff --git a/app/old/Commands/SendTestPaymentNotification.php b/app/old/Commands/SendTestPaymentNotification.php deleted file mode 100644 index 611aac97..00000000 --- a/app/old/Commands/SendTestPaymentNotification.php +++ /dev/null @@ -1,150 +0,0 @@ -attendance['timezone'] ?? 'UTC'); - $tz = new \DateTimeZone($tzName); - $now = new \DateTime('now', $tz); - $year = (int)$now->format('Y'); - $month= (int)$now->format('n'); - $schoolYear = (string) ($config->getConfig('school_year') ?? $year); - - $userRow = $user->where('email', $email)->first(); - $parentId = (int)($userRow['id'] ?? 0); - - $invRows = $parentId ? $invoice->getAllInvoicesByUserIds([$parentId], $schoolYear) : []; - $totalBalance = 0.0; $latestInvoiceId = null; - foreach ($invRows as $ir) { - $totalBalance += (float)($ir['balance'] ?? 0); - if ($latestInvoiceId === null) $latestInvoiceId = (int)($ir['id'] ?? 0); - } - - // Secondary guardian if available - $ccEmail = null; - if ($parentId) { - $row = $fam->where('user_id', $parentId)->first(); - if ($row && !empty($row['family_id'])) { - $others = $fam->where('family_id', (int)$row['family_id']) - ->where('user_id !=', $parentId) - ->where('receive_emails', 1)->findAll(); - foreach ($others as $g) { - $ccEmail = $user->select('email')->find((int)$g['user_id'])['email'] ?? null; - if ($ccEmail && filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) break; - $ccEmail = null; - } - } - } - - // Compose body (reuse layout) - $parentName = trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? '')); - $monthYear = $now->format('F Y'); - $subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear); - $greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,'; - $balanceFmt = '$' . number_format($totalBalance, 2); - $intro = ($type === 'no_payment') - ? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet." - : "This is your monthly installment reminder for {$schoolYear}."; - - $bodyHtml = << -

Monthly Tuition Reminder (Test)

-

{$greeting}

-

{$intro}

-

Current Outstanding Balance: {$balanceFmt}

-

- As a friendly reminder, our tuition installments are due at the beginning of each month. - You can review your invoice and payment options by logging in to your parent portal. -

-

Thank you for your prompt attention.

-

Reminder Period: {$monthYear}

- - HTML; - // Compute remaining and max installments similar to Manual Payment UI - $installmentEndRaw = (string)$config->getConfig('installment_date'); - $tz = new \DateTimeZone($tzName); - $today = new \DateTimeImmutable('today', $tz); - $end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; } - $remMonths = 0; - if ($end) { - $y = (int)$end->format('Y') - (int)$today->format('Y'); - $m = (int)$end->format('n') - (int)$today->format('n'); - $remMonths = $y * 12 + $m; - if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1; - if ($remMonths < 0) $remMonths = 0; - } - $remainingInst = max(1, $remMonths); - $maxInst = max(($remMonths ?: 0), ($totalBalance > 0 ? 2 : 0)); - $instDueFmt = '$' . number_format($remainingInst > 0 ? ($totalBalance / $remainingInst) : 0, 2); - - $bodyHtml .= "
    " - . "
  • Remaining installments: {$remainingInst}
  • " - . "
  • Suggested installment this month: {$instDueFmt}
  • " - . "
  • Maximum installments available: {$maxInst}
  • " - . "
"; - - $body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]); - - $ok = $mailer->send($email, $subject, $body, 'finance'); - if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) { - $mailer->send($ccEmail, $subject, $body, 'finance'); - } - - $existing = $logs->where('parent_id', $parentId) - ->where('period_year', $year) - ->where('period_month', $month) - ->where('type', $type) - ->first(); - - $payload = [ - 'parent_id' => $parentId, - 'invoice_id' => $latestInvoiceId, - 'school_year' => $schoolYear, - 'period_year' => $year, - 'period_month' => $month, - 'type' => $type, - 'to_email' => $email, - 'cc_email' => $ccEmail, - 'head_fa_notified' => 0, - 'subject' => $subject, - 'body' => $body, - 'status' => $ok ? 'sent' : 'failed', - 'error_message' => $ok ? null : 'Email send failed (see logs)', - 'balance_snapshot' => $totalBalance, - 'sent_at' => $now->format('Y-m-d H:i:s'), - ]; - if ($existing) $logs->update($existing['id'], $payload); else $logs->insert($payload); - - CLI::write(($ok ? 'Sent' : 'Failed') . " test reminder to {$email}", $ok ? 'green' : 'red'); - } -} diff --git a/app/old/Commands/SyncPaypalPayments.php b/app/old/Commands/SyncPaypalPayments.php deleted file mode 100644 index 05667ca8..00000000 --- a/app/old/Commands/SyncPaypalPayments.php +++ /dev/null @@ -1,232 +0,0 @@ -configModel = new ConfigurationModel(); - $this->paypalModel = new PayPalPaymentModel(); - $this->paymentModel = new PaymentModel(); - $this->userModel = new UserModel(); - $this->invoiceModel = new InvoiceModel(); - $this->studentModel = new StudentModel(); - $this->enrollmentModel = new EnrollmentModel(); - - $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); - } - - public function run(array $params) - { - $dryRun = CLI::getOption('dry-run'); - $reportOnly = CLI::getOption('report-only'); - $mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE'); - - $paypalEntries = $this->paypalModel - ->where('status', 'COMPLETED') - ->where('synced', 0) - ->where('sync_attempts <', 3) - ->where('transaction_id IS NOT NULL') - ->findAll(); - - $syncedCount = 0; - $failed = []; - - foreach ($paypalEntries as $entry) { - $parentId = null; - $invoiceId = 0; - - $users = $this->userModel->getUsersBySchoolId($entry['parent_school_id']); - $user = $users[0] ?? null; - - // Always increment sync_attempts unless report-only - if (!$reportOnly) { - $this->paypalModel->update($entry['id'], [ - 'sync_attempts' => $entry['sync_attempts'] + 1 - ]); - } - - if ($user) { - $parentId = $user['id']; - - $invoice = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear); - - if (!$reportOnly && !$dryRun) { - if ($invoice) { - $invoiceId = $invoice['id']; - - $success = $this->processPayment( - $invoiceId, - $entry['amount'], - 'PayPal', - null, - $entry['transaction_id'], - date('Y-m-d', strtotime($entry['created_at'])), - $this->schoolYear, - $this->semester - ); - - if (!$success) { - $failed[] = $entry['transaction_id']; - continue; - } - } else { - $this->paymentModel->insert([ - 'parent_id' => $parentId, - 'invoice_id' => 0, - 'total_amount' => $entry['amount'], - 'paid_amount' => $entry['amount'], - 'balance' => 0.00, - 'number_of_installments' => 1, - 'transaction_id' => $entry['transaction_id'], - 'payment_method' => 'PayPal', - 'payment_date' => date('Y-m-d', strtotime($entry['created_at'])), - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'status' => 'Completed', - 'updated_by' => null, - ]); - } - - // Mark as synced only in LIVE mode - $this->paypalModel->update($entry['id'], ['synced' => 1]); - } - - $syncedCount++; - } else { - log_message('error', "[PAYPAL SYNC FAILED] No user found for parent_school_id: {$entry['parent_school_id']}"); - $failed[] = $entry['transaction_id']; - } - } - - // === Logging === - log_message('info', "[$mode] PAYPAL SYNC: $syncedCount processed."); - if (!empty($failed)) { - log_message('error', "[$mode] PAYPAL SYNC Failed: " . implode(', ', $failed)); - } - - // === CLI Output === - CLI::write("[$mode] $syncedCount PayPal payments processed.", 'green'); - if (!empty($failed)) { - CLI::error("[$mode] Failed transactions: " . implode(', ', $failed)); - } - - // === Email Report: Only if there's any update === - if ($syncedCount > 0 || !empty($failed)) { - helper('email'); - $email = \Config\Services::email(); - $email->setTo('support@alrahmaisgl.org'); - $email->setFrom('no-parentsreply@alrahmaisgl.org', 'PayPal Sync Report'); - $email->setSubject("[$mode] PayPal Sync Report - " . date('Y-m-d H:i')); - - $body = "PayPal Sync Mode: $mode\n\n"; - $body .= "$syncedCount PayPal payments processed.\n\n"; - - if (!empty($failed)) { - $body .= count($failed) . " failed transactions:\n"; - $body .= implode("\n", $failed); - } else { - $body .= "No failed transactions.\n"; - } - - $email->setMessage(nl2br($body)); - - if ($email->send()) { - CLI::write("[$mode] Email report sent successfully.", 'yellow'); - } else { - CLI::error("[$mode] Failed to send email report."); - log_message('error', 'Email send error: ' . $email->printDebugger(['headers'])); - } - } else { - log_message('info', "[$mode] No PayPal sync updates. Email not sent."); - CLI::write("[$mode] No changes to report. Email not sent.", 'blue'); - } - } - - private function processPayment($invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null) - { - $invoice = $this->invoiceModel->find($invoiceId); - if (!$invoice) { - return false; - } - - $transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time(); - $paymentDate = $paymentDate ?? date('Y-m-d'); - - $newPaid = $invoice['paid_amount'] + $amount; - $newBalance = $invoice['balance'] - $amount; - - $invoiceUpdateData = [ - 'paid_amount' => $newPaid, - 'balance' => $newBalance, - 'status' => ($newBalance <= 0) ? 'Paid' : $invoice['status'], - ]; - - if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) { - return false; - } - - $this->paymentModel->insert([ - 'parent_id' => $invoice['parent_id'], - 'invoice_id' => $invoiceId, - 'total_amount' => $invoice['total_amount'], - 'paid_amount' => $amount, - 'balance' => $newBalance, - 'number_of_installments' => 1, - 'transaction_id' => $transactionId, - 'payment_method' => $paymentMethod, - 'payment_date' => $paymentDate, - 'status' => ($newBalance <= 0) ? 'Full' : 'Partial', - 'check_file' => $checkFile, - 'updated_by' => null, // Avoid using session()->get() in CLI - 'school_year' => $schoolYear, - 'semester' => $semester - ]); - - $this->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear); - return true; - } - - private function updateEnrollmentStatusIfPaid($invoiceId, $schoolYear) - { - $invoice = $this->invoiceModel->find($invoiceId); - if (!$invoice || $invoice['balance'] > 0) { - return; - } - - $students = $this->studentModel->where('parent_id', $invoice['parent_id']) - ->where('school_year', $schoolYear) - ->findAll(); - - foreach ($students as $student) { - $this->enrollmentModel->set(['enrollment_status' => 'enrolled']) - ->where('student_id', $student['id']) - ->update(); - } - } -} diff --git a/app/old/Events/DeleteUnverifiedUser.php b/app/old/Events/DeleteUnverifiedUser.php deleted file mode 100644 index 46175e88..00000000 --- a/app/old/Events/DeleteUnverifiedUser.php +++ /dev/null @@ -1,46 +0,0 @@ -userId = $userId; - } - - public static function deleteAfterTimeout($userId) - { - $model = new self(); - - // Define the timeout period - $timeoutPeriod = 24 * 60 * 60; // 1 day in seconds - - // Retrieve the user's record - $user = $model->find($userId); - - if ($user) { - // Get the most recent timestamp to compare with the current time - $lastActivityTime = strtotime($user['updated_at'] ?? $user['created_at']); - - // Calculate the time difference - $timeSinceLastActivity = time() - $lastActivityTime; - - // Check if the time difference exceeds the timeout period - if ($timeSinceLastActivity > $timeoutPeriod) { - // Delete the user record - return $model->delete($userId); - } else { - // Timeout period has not yet passed - return false; // Or you can return a message indicating no action taken - } - } - - // Return false if the user is not found - return false; - } - -} - diff --git a/app/old/Helpers/.gitkeep b/app/old/Helpers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/old/Helpers/AuthHelper.php b/app/old/Helpers/AuthHelper.php deleted file mode 100644 index d4e58111..00000000 --- a/app/old/Helpers/AuthHelper.php +++ /dev/null @@ -1,28 +0,0 @@ -where('name', $permissionName)->first(); - if (!$permission) { - return false; - } - - $permissionId = $permission['id']; - $roles = $userRoleModel->where('user_id', $userId)->findAll(); - foreach ($roles as $role) { - $permissions = $rolePermissionModel->where('role_id', $role['role_id'])->where('permission_id', $permissionId)->findAll(); - if (!empty($permissions)) { - return true; - } - } - - return false; -} diff --git a/app/old/Helpers/DocumentHelper.php b/app/old/Helpers/DocumentHelper.php deleted file mode 100644 index e69de29b..00000000 diff --git a/app/old/Helpers/GlobalConfigHelper.php b/app/old/Helpers/GlobalConfigHelper.php deleted file mode 100644 index a94ec508..00000000 --- a/app/old/Helpers/GlobalConfigHelper.php +++ /dev/null @@ -1,18 +0,0 @@ -getConfig('semester'); - } -} - -if (!function_exists('getSchoolYear')) { - function getSchoolYear() { - $configModel = new \App\Models\ConfigurationModel(); - return $configModel->getConfig('school_year'); - } -} diff --git a/app/old/Helpers/jwt_helper.php b/app/old/Helpers/jwt_helper.php deleted file mode 100644 index fafec765..00000000 --- a/app/old/Helpers/jwt_helper.php +++ /dev/null @@ -1,86 +0,0 @@ - 'JWT', 'alg' => $alg]; - - $segments = []; - $segments[] = base64url_encode(json_encode($header, JSON_UNESCAPED_SLASHES)); - $segments[] = base64url_encode(json_encode($payload, JSON_UNESCAPED_SLASHES)); - - $signingInput = implode('.', $segments); - - switch ($alg) { - case 'HS256': - $signature = hash_hmac('sha256', $signingInput, $secret, true); - break; - default: - throw new \InvalidArgumentException('Unsupported JWT alg: ' . $alg); - } - - $segments[] = base64url_encode($signature); - return implode('.', $segments); - } -} - -if (!function_exists('base64url_decode')) { - function base64url_decode(string $data): string - { - $padding = strlen($data) % 4; - if ($padding > 0) { - $data .= str_repeat('=', 4 - $padding); - } - return base64_decode(strtr($data, '-_', '+/')); - } -} - -if (!function_exists('jwt_decode')) { - function jwt_decode(string $token, string $secret, string $alg = 'HS256'): ?array - { - $parts = explode('.', $token); - if (count($parts) !== 3) { - return null; - } - - [$header64, $payload64, $signature64] = $parts; - $header = json_decode(base64url_decode($header64), true); - $payload = json_decode(base64url_decode($payload64), true); - $signature = base64url_decode($signature64); - - if (!is_array($header) || !is_array($payload)) { - return null; - } - - $signingInput = $header64 . '.' . $payload64; - - switch ($header['alg'] ?? $alg) { - case 'HS256': - $expected = hash_hmac('sha256', $signingInput, $secret, true); - break; - default: - return null; - } - - if (!hash_equals($expected, $signature)) { - return null; - } - - return $payload; - } -} diff --git a/app/old/Helpers/pbkdf2_helper.php b/app/old/Helpers/pbkdf2_helper.php deleted file mode 100644 index bb91cb0d..00000000 --- a/app/old/Helpers/pbkdf2_helper.php +++ /dev/null @@ -1,39 +0,0 @@ -get('teacher_scores_selected_semester') ?? '')); - if ($selected !== '') { - return ucfirst(strtolower($selected)); - } - - $fallback = trim((string) ($session->get('semester') ?? '')); - if ($fallback !== '') { - return ucfirst(strtolower($fallback)); - } - - $config = new ConfigurationModel(); - $configSemester = trim((string) ($config->getConfig('semester') ?? '')); - if ($configSemester !== '') { - return ucfirst(strtolower($configSemester)); - } - - return 'Fall'; - } -} diff --git a/app/old/Helpers/time_helper.php b/app/old/Helpers/time_helper.php deleted file mode 100644 index ce58f0f6..00000000 --- a/app/old/Helpers/time_helper.php +++ /dev/null @@ -1,75 +0,0 @@ -userTimezone(); - } -} - -if (! function_exists('server_timezone')) { - function server_timezone(): string - { - return service('timeService')->serverTimezone(); - } -} - -if (! function_exists('utc_now')) { - function utc_now(string $format = 'Y-m-d H:i:s'): string - { - return service('timeService')->nowUTC()->format($format); - } -} - -if (! function_exists('local_now')) { - function local_now(string $format = 'Y-m-d H:i:s', ?string $tz = null): string - { - return service('timeService')->nowLocal($tz)->format($format); - } -} - -if (! function_exists('to_utc')) { - /** - * Convert a time to UTC with optional source TZ and format. - * - * @param string|Time|\DateTimeInterface|null $value - */ - function to_utc($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string - { - return service('timeService')->toUTC($value, $fromTz, $format); - } -} - -if (! function_exists('to_local')) { - /** - * Convert a time to user-local with optional source TZ and format. - * - * @param string|Time|\DateTimeInterface|null $value - */ - function to_local($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string - { - return service('timeService')->toLocal($value, $sourceTz, $targetTz, $format); - } -} - -if (! function_exists('local_date')) { - /** - * Format to user-local date (Y-m-d by default). Assumes input is UTC unless source TZ provided. - */ - function local_date($value, string $format = 'Y-m-d', ?string $sourceTz = null): string - { - return service('timeService')->formatLocal($value, $format, $sourceTz); - } -} - -if (! function_exists('local_datetime')) { - /** - * Format to user-local datetime (Y-m-d H:i by default). Assumes input is UTC unless source TZ provided. - */ - function local_datetime($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string - { - return service('timeService')->formatLocal($value, $format, $sourceTz); - } -} diff --git a/app/old/Interfaces/ScoreCalculatorInterface.php b/app/old/Interfaces/ScoreCalculatorInterface.php deleted file mode 100644 index 2e4aa518..00000000 --- a/app/old/Interfaces/ScoreCalculatorInterface.php +++ /dev/null @@ -1,17 +0,0 @@ -comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('users:delete-unverified')->everyFifteenMinutes(); diff --git a/tests/Feature/Console/DeleteUnverifiedUsersCommandTest.php b/tests/Feature/Console/DeleteUnverifiedUsersCommandTest.php new file mode 100644 index 00000000..1cdc02c3 --- /dev/null +++ b/tests/Feature/Console/DeleteUnverifiedUsersCommandTest.php @@ -0,0 +1,51 @@ +insert([ + 'id' => 20, + 'firstname' => 'Old', + 'lastname' => 'User', + 'email' => 'old@example.com', + 'status' => 'Inactive', + 'is_verified' => 0, + 'created_at' => now()->subDays(2), + 'updated_at' => now()->subDays(2), + ]); + + DB::table('users')->insert([ + 'id' => 21, + 'firstname' => 'Recent', + 'lastname' => 'User', + 'email' => 'recent@example.com', + 'status' => 'Inactive', + 'is_verified' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Event::fake(); + + $this->artisan('users:delete-unverified', ['--timeout' => 3600])->assertExitCode(0); + + Event::assertDispatched(DeleteUnverifiedUser::class, function ($event) { + return $event->userId === 20; + }); + + Event::assertNotDispatched(DeleteUnverifiedUser::class, function ($event) { + return $event->userId === 21; + }); + } +} diff --git a/tests/Unit/Listeners/DeleteUnverifiedUserListenerTest.php b/tests/Unit/Listeners/DeleteUnverifiedUserListenerTest.php new file mode 100644 index 00000000..2663bcd8 --- /dev/null +++ b/tests/Unit/Listeners/DeleteUnverifiedUserListenerTest.php @@ -0,0 +1,23 @@ +shouldReceive('deleteAfterTimeout')->once()->with(15)->andReturn(['ok' => true]); + + $listener = new DeleteUnverifiedUserListener($service); + $listener->handle(new DeleteUnverifiedUser(15)); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Services/Attendance/AttendanceCommentServiceTest.php b/tests/Unit/Services/Attendance/AttendanceCommentServiceTest.php new file mode 100644 index 00000000..fc7e6273 --- /dev/null +++ b/tests/Unit/Services/Attendance/AttendanceCommentServiceTest.php @@ -0,0 +1,51 @@ +insert([ + [ + 'min_score' => 90, + 'max_score' => 100, + 'template_text' => '{name} attendance is excellent.', + 'is_active' => 1, + ], + [ + 'min_score' => 0, + 'max_score' => 89, + 'template_text' => 'needs improvement in attendance.', + 'is_active' => 1, + ], + ]); + + $service = new AttendanceCommentService(); + $comment = $service->commentFromScore(95, 'James'); + + $this->assertSame("James' attendance is excellent.", $comment); + } + + public function test_comment_from_score_falls_back_to_this_student(): void + { + DB::table('attendance_comment_template')->insert([ + 'min_score' => 0, + 'max_score' => 100, + 'template_text' => 'needs improvement in attendance.', + 'is_active' => 1, + ]); + + $service = new AttendanceCommentService(); + $comment = $service->commentFromScore(70); + + $this->assertSame("This student's needs improvement in attendance.", $comment); + } +} diff --git a/tests/Unit/Services/Auth/PermissionCheckServiceTest.php b/tests/Unit/Services/Auth/PermissionCheckServiceTest.php new file mode 100644 index 00000000..00a76049 --- /dev/null +++ b/tests/Unit/Services/Auth/PermissionCheckServiceTest.php @@ -0,0 +1,42 @@ +insertGetId([ + 'name' => 'edit_students', + 'description' => 'Edit students', + ]); + + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'admin', + 'priority' => 1, + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 10, + 'role_id' => $roleId, + ]); + + DB::table('role_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + 'can_read' => 1, + ]); + + $service = new PermissionCheckService(); + + $this->assertTrue($service->hasPermission(10, 'edit_students')); + $this->assertFalse($service->hasPermission(10, 'missing_perm')); + } +} diff --git a/tests/Unit/Services/School/SemesterSelectionServiceTest.php b/tests/Unit/Services/School/SemesterSelectionServiceTest.php new file mode 100644 index 00000000..62a69864 --- /dev/null +++ b/tests/Unit/Services/School/SemesterSelectionServiceTest.php @@ -0,0 +1,36 @@ +put('teacher_scores_selected_semester', 'spring'); + + $service = new SemesterSelectionService(); + + $this->assertSame('Spring', $service->selectedTeacherSemester()); + } + + public function test_selected_teacher_semester_falls_back_to_config(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + session()->forget('teacher_scores_selected_semester'); + session()->forget('semester'); + + $service = new SemesterSelectionService(); + + $this->assertSame('Fall', $service->selectedTeacherSemester()); + } +} diff --git a/tests/Unit/Services/Scores/AttendanceCalculatorTest.php b/tests/Unit/Services/Scores/AttendanceCalculatorTest.php new file mode 100644 index 00000000..9ae89c1e --- /dev/null +++ b/tests/Unit/Services/Scores/AttendanceCalculatorTest.php @@ -0,0 +1,40 @@ +insert([ + ['config_key' => 'total_semester1_days', 'config_value' => '10'], + ]); + + DB::table('attendance_record')->insert([ + 'class_section_id' => 1, + 'student_id' => 10, + 'school_id' => 'S1', + 'total_absence' => 2, + 'total_late' => 0, + 'total_presence' => 0, + 'total_attendance' => 0, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ]); + + $service = new AttendanceCalculator(new AttendanceRecord(), new Configuration(), new CalendarEvent()); + $result = $service->calculate(10, 'Fall', '2024-2025'); + + $this->assertSame(90.0, $result['attendance_score']); + } +} diff --git a/tests/Unit/Services/Scores/HomeworkCalculatorTest.php b/tests/Unit/Services/Scores/HomeworkCalculatorTest.php new file mode 100644 index 00000000..cb47983f --- /dev/null +++ b/tests/Unit/Services/Scores/HomeworkCalculatorTest.php @@ -0,0 +1,55 @@ +insert([ + [ + 'student_id' => 5, + 'school_id' => 'S1', + 'class_section_id' => 1, + 'updated_by' => 1, + 'homework_index' => 1, + 'score' => 80, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 5, + 'school_id' => 'S1', + 'class_section_id' => 1, + 'updated_by' => 1, + 'homework_index' => 2, + 'score' => 90, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 5, + 'school_id' => 'S1', + 'class_section_id' => 1, + 'updated_by' => 1, + 'homework_index' => 3, + 'score' => null, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + ]); + + $service = new HomeworkCalculator(new Homework()); + $result = $service->calculate(5, 'Fall', '2024-2025', 1); + + $this->assertSame(85.0, $result['homework_avg']); + } +} diff --git a/tests/Unit/Services/Scores/ProjectCalculatorTest.php b/tests/Unit/Services/Scores/ProjectCalculatorTest.php new file mode 100644 index 00000000..f1e22a49 --- /dev/null +++ b/tests/Unit/Services/Scores/ProjectCalculatorTest.php @@ -0,0 +1,55 @@ +insert([ + [ + 'student_id' => 9, + 'school_id' => 'S1', + 'class_section_id' => 1, + 'updated_by' => 1, + 'project_index' => 1, + 'score' => 95, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 9, + 'school_id' => 'S1', + 'class_section_id' => 1, + 'updated_by' => 1, + 'project_index' => 2, + 'score' => 85, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 9, + 'school_id' => 'S1', + 'class_section_id' => 2, + 'updated_by' => 1, + 'project_index' => 3, + 'score' => 100, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + ]); + + $service = new ProjectCalculator(new Project()); + $result = $service->calculate(9, 'Fall', '2024-2025', 1); + + $this->assertSame(90.0, $result['project_avg']); + } +} diff --git a/tests/Unit/Services/Scores/QuizCalculatorTest.php b/tests/Unit/Services/Scores/QuizCalculatorTest.php new file mode 100644 index 00000000..33dfd0d2 --- /dev/null +++ b/tests/Unit/Services/Scores/QuizCalculatorTest.php @@ -0,0 +1,64 @@ +insert([ + [ + 'student_id' => 7, + 'school_id' => 'S1', + 'class_section_id' => 2, + 'updated_by' => 1, + 'quiz_index' => 1, + 'score' => 70, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 7, + 'school_id' => 'S1', + 'class_section_id' => 2, + 'updated_by' => 1, + 'quiz_index' => 2, + 'score' => 90, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 7, + 'school_id' => 'S1', + 'class_section_id' => 3, + 'updated_by' => 1, + 'quiz_index' => 3, + 'score' => 100, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + [ + 'student_id' => 7, + 'school_id' => 'S1', + 'class_section_id' => 2, + 'updated_by' => 1, + 'quiz_index' => 4, + 'score' => null, + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ], + ]); + + $service = new QuizCalculator(); + $result = $service->calculate(7, 'Fall', '2024-2025', 2); + + $this->assertSame(80.0, $result['quiz_avg']); + } +} diff --git a/tests/Unit/Services/Security/Pbkdf2HasherTest.php b/tests/Unit/Services/Security/Pbkdf2HasherTest.php new file mode 100644 index 00000000..49b7d223 --- /dev/null +++ b/tests/Unit/Services/Security/Pbkdf2HasherTest.php @@ -0,0 +1,18 @@ +hash('secret'); + + $this->assertTrue($hasher->verify('secret', $hash)); + $this->assertFalse($hasher->verify('wrong', $hash)); + } +} diff --git a/tests/Unit/Services/System/GlobalConfigServiceTest.php b/tests/Unit/Services/System/GlobalConfigServiceTest.php new file mode 100644 index 00000000..f9895332 --- /dev/null +++ b/tests/Unit/Services/System/GlobalConfigServiceTest.php @@ -0,0 +1,26 @@ +insert([ + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'school_year', 'config_value' => '2024-2025'], + ]); + + $service = new GlobalConfigService(); + + $this->assertSame('Fall', $service->getSemester()); + $this->assertSame('2024-2025', $service->getSchoolYear()); + } +} diff --git a/tests/Unit/Services/System/TimeServiceTest.php b/tests/Unit/Services/System/TimeServiceTest.php new file mode 100644 index 00000000..00c24b1c --- /dev/null +++ b/tests/Unit/Services/System/TimeServiceTest.php @@ -0,0 +1,18 @@ +toUtc('2025-01-01 12:00:00', 'America/New_York'); + $local = $service->toLocal($utc, 'UTC', 'America/New_York'); + + $this->assertSame('2025-01-01 12:00:00', $local); + } +} diff --git a/tests/Unit/Services/Users/DeleteUnverifiedUserServiceTest.php b/tests/Unit/Services/Users/DeleteUnverifiedUserServiceTest.php new file mode 100644 index 00000000..f7a77698 --- /dev/null +++ b/tests/Unit/Services/Users/DeleteUnverifiedUserServiceTest.php @@ -0,0 +1,68 @@ +insert([ + 'id' => 10, + 'firstname' => 'Unverified', + 'lastname' => 'User', + 'email' => 'u@example.com', + 'status' => 'Inactive', + 'is_verified' => 0, + 'created_at' => now()->subDays(2), + 'updated_at' => now()->subDays(2), + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 10, + 'role_id' => 1, + ]); + + $accountEvents = Mockery::mock(AccountEventService::class); + $accountEvents->shouldReceive('deleteUnverifiedUser')->once(); + + $service = new DeleteUnverifiedUserService($accountEvents); + $result = $service->deleteAfterTimeout(10, 3600); + + $this->assertTrue($result['ok']); + $this->assertDatabaseMissing('users', ['id' => 10]); + $this->assertDatabaseMissing('user_roles', ['user_id' => 10]); + } + + public function test_delete_after_timeout_skips_recent_user(): void + { + DB::table('users')->insert([ + 'id' => 11, + 'firstname' => 'Recent', + 'lastname' => 'User', + 'email' => 'recent@example.com', + 'status' => 'Inactive', + 'is_verified' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $accountEvents = Mockery::mock(AccountEventService::class); + $accountEvents->shouldNotReceive('deleteUnverifiedUser'); + + $service = new DeleteUnverifiedUserService($accountEvents); + $result = $service->deleteAfterTimeout(11, 86400); + + $this->assertFalse($result['ok']); + $this->assertSame('not_expired', $result['reason']); + $this->assertDatabaseHas('users', ['id' => 11]); + } +}