add services logic

This commit is contained in:
root
2026-03-11 01:38:20 -04:00
parent 182036cc41
commit 3e6c577085
47 changed files with 918 additions and 1772 deletions
@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Events\DeleteUnverifiedUser;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
class DeleteUnverifiedUsersCommand extends Command
{
protected $signature = 'users:delete-unverified {--timeout=86400}';
protected $description = 'Delete unverified users older than the configured timeout (seconds).';
public function handle(): int
{
$timeout = (int) $this->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;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Events;
class DeleteUnverifiedUser
{
public function __construct(public int $userId)
{
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Listeners;
use App\Events\DeleteUnverifiedUser;
use App\Services\Users\DeleteUnverifiedUserService;
class DeleteUnverifiedUserListener
{
public function __construct(private DeleteUnverifiedUserService $service)
{
}
public function handle(DeleteUnverifiedUser $event): void
{
$this->service->deleteAfterTimeout($event->userId);
}
}
+5
View File
@@ -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,
],
@@ -1,16 +1,21 @@
<?php
use App\Models\AttendanceCommentTemplateModel;
namespace App\Services\Attendance;
if (!function_exists('attendance_comment_from_score')) {
function attendance_comment_from_score($score, string $firstName = ''): ?string
use App\Models\AttendanceCommentTemplate;
class AttendanceCommentService
{
private ?array $templates = null;
public function commentFromScore($score, string $firstName = ''): ?string
{
if ($score === null || $score === '') {
return null;
}
$score = (float) $score;
$template = attendance_comment_template_for_score($score);
$template = $this->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;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Services\Auth;
use Illuminate\Support\Facades\DB;
class PermissionCheckService
{
public function hasPermission(int $userId, string $permissionName): bool
{
$permissionId = DB::table('permissions')
->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();
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Services\School;
use App\Models\Configuration;
class SemesterSelectionService
{
public function selectedTeacherSemester(): string
{
$selected = trim((string) (session()->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));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Security;
class Pbkdf2Hasher
{
public function hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
{
$salt = random_bytes(16);
$saltHex = bin2hex($salt);
$derived = hash_pbkdf2($algo, $password, $salt, $iterations, $length, true);
$derivedHex = bin2hex($derived);
return implode('$', [$algo, $iterations, $saltHex, $derivedHex]);
}
public function verify(string $password, string $storedHash): bool
{
$parts = explode('$', $storedHash);
if (count($parts) !== 4) {
return false;
}
[$algo, $iterations, $saltHex, $derivedHex] = $parts;
if (strlen($saltHex) % 2 !== 0 || strlen($derivedHex) % 2 !== 0) {
return false;
}
$salt = hex2bin($saltHex);
$derived = hex2bin($derivedHex);
if ($salt === false || $derived === false) {
return false;
}
$length = strlen($derived);
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, $length, true);
return hash_equals($derived, $calc);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\System;
use App\Models\Configuration;
class GlobalConfigService
{
public function getSemester(): ?string
{
return Configuration::getConfig('semester');
}
public function getSchoolYear(): ?string
{
return Configuration::getConfig('school_year');
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Services\System;
use Carbon\CarbonImmutable;
use DateTimeInterface;
class TimeService
{
public function userTimezone(): 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');
}
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;
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Services\Users;
use App\Models\User;
use App\Services\School\AccountEventService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeleteUnverifiedUserService
{
public function __construct(private AccountEventService $accountEvents)
{
}
public function deleteAfterTimeout(int $userId, int $timeoutSeconds = 86400): array
{
$user = User::query()->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];
}
}
@@ -1,46 +0,0 @@
<?php
namespace App\Commands;
use App\Libraries\AttendanceAutoPublish as AutoPublishLib;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class AttendanceAutoPublishCommand extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:auto-publish';
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
public function run(array $params)
{
$db = db_connect();
$zone = AutoPublishLib::tz();
$now = new \DateTimeImmutable('now', $zone);
$nowStr = $now->format('Y-m-d H:i:s');
$cutoffDate = AutoPublishLib::secondSundayBackwardDate($now);
$builder = $db->table('attendance_day');
$builder->where('status', 'submitted')
->groupStart()
->where('auto_publish_at <=', $nowStr)
->orGroupStart()
->where('auto_publish_at IS NULL', null, false)
->where('date <=', $cutoffDate)
->groupEnd()
->groupEnd();
$count = $builder->countAllResults(false); // keep the WHEREs
if ($count > 0) {
$builder->update([
'status' => 'published',
'published_by' => 0, // system
'published_at' => $nowStr,
'updated_at' => $nowStr,
]);
}
CLI::write("Auto-publish checked at {$nowStr} (TZ: {$zone->getName()}). Published rows: {$count}.");
}
}
-35
View File
@@ -1,35 +0,0 @@
<?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');
}
}
@@ -1,38 +0,0 @@
<?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');
}
}
@@ -1,32 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PasswordResetRequestModel;
use CodeIgniter\I18n\Time;
class CleanupPasswordResets extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'cleanup:password-resets';
protected $description = 'Delete password reset requests older than 30 days';
public function run(array $params)
{
$model = new PasswordResetRequestModel();
$threshold = Time::now()->subDays(30)->toDateTimeString();
$count = $model->where('requested_at <', $threshold)->delete();
CLI::write("Deleted $count old password reset request(s).", 'green');
}
}
/*
Add this cron job to run every 24hrs
0 0 * * * /usr/bin/php /path/to/project/public/index.php cleanup:password-resets >> /path/to/project/writable/logs/cleanup.log 2>&1
*/
-232
View File
@@ -1,232 +0,0 @@
<?php
namespace App\Commands;
use App\Models\ConfigurationModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use DateTime;
use DateTimeZone;
class ConfigUpdate extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'config:update';
protected $description = 'Run a configuration update task (weekly cron).';
protected $arguments = [];
protected $usage = 'php spark config:update [task] [--task task|-t task] [--dry] [--force] [--tz=<timezone>]';
protected $options = [
'task' => 'Task name (or pass as first positional arg)',
't' => 'Short form of --task',
'dry' => 'Dry run (no DB writes)',
'force' => 'Ignore lock and run anyway',
'tz' => 'Timezone (default: configured school timezone)',
];
/** @var ConfigurationModel */
protected $configModel;
/** Map of available task names -> handler methods. */
protected array $tasks = [
'update_date_age_reference' => 'taskUpdateDateAgeReference',
'enable_attendance_on' => 'taskEnableAttendanceOn',
'enable_attendance_off' => 'taskEnableAttendanceOff',
'set_semester_spring' => 'taskSetSemesterSpring',
'set_semester_fall' => 'taskSetSemesterFall',
];
/**
* Set enable_attendance = 1
*/
protected function taskEnableAttendanceOn(bool $dry, DateTimeZone $tz): bool
{
return $this->setConfig('enable_attendance', '1', $dry);
}
/**
* Set enable_attendance = 0
*/
protected function taskEnableAttendanceOff(bool $dry, DateTimeZone $tz): bool
{
return $this->setConfig('enable_attendance', '0', $dry);
}
protected function taskUpdateDateAgeReference(bool $dry, DateTimeZone $tz): bool
{
$now = new DateTime('now', $tz);
$isJune1 = ($now->format('n') === '6' && $now->format('j') === '1');
$forced = (CLI::getOption('force') !== null);
if (!$isJune1 && !$forced) {
CLI::write(
"Today is {$now->format('Y-m-d')} (not June 1) — skipping. Use --force to override.",
'yellow'
);
return true; // no-op, not an error
}
$year = (int) $now->format('Y');
$value = sprintf('%04d-12-31', $year);
CLI::write("Set date_age_reference = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
// Use your model method
$ok = (bool) $this->configModel->setConfigValueByKey('date_age_reference', $value);
if ($ok) {
CLI::write("date_age_reference updated to {$value}", 'green');
} else {
CLI::error("Failed to update date_age_reference");
}
return $ok;
}
protected function taskSetSemesterSpring(bool $dry, DateTimeZone $tz): bool
{
$now = new DateTime('now', $tz);
$isFeb1 = ($now->format('n') === '2' && $now->format('j') === '1');
$forced = (CLI::getOption('force') !== null);
if (!$isFeb1 && !$forced) {
CLI::write(
"Today is {$now->format('Y-m-d')} (not Feb 1) — skipping. Use --force to override.",
'yellow'
);
return true; // no-op is success
}
CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring');
}
protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool
{
$now = new DateTime('now', $tz);
$isJun1 = ($now->format('n') === '6' && $now->format('j') === '1');
$forced = (CLI::getOption('force') !== null);
if (!$isJun1 && !$forced) {
CLI::write(
"Today is {$now->format('Y-m-d')} (not Jun 1) — skipping. Use --force to override.",
'yellow'
);
return true;
}
CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall');
}
public function run(array $params)
{
$this->configModel = model(ConfigurationModel::class);
$tz = $this->getOptionString('tz', $params) ?? ((string)(config('School')->attendance['timezone'] ?? 'UTC'));
$task = $this->getOptionString('task', $params, 't');
// Fallback: first positional arg (php spark config:update enable_attendance_on)
if (!$task && !empty($params) && strpos($params[0], '-') !== 0) {
$task = trim($params[0]);
}
$dry = $this->hasFlag('dry', $params);
$force = $this->hasFlag('force', $params);
$dtz = new DateTimeZone($tz);
if ($task === '' || !isset($this->tasks[$task])) {
CLI::error('Invalid or missing --task. Available: ' . implode(', ', array_keys($this->tasks)));
return;
}
// Per-task lock
$lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "ci4_config_update_{$task}.lock";
$fp = @fopen($lockFile, 'c+');
if (!$fp) {
CLI::error("Unable to open lock file: $lockFile");
return;
}
if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) {
CLI::error("Task '$task' is already running (lock held). Use --force to override.");
fclose($fp);
return;
}
try {
$method = $this->tasks[$task];
CLI::write("Running task: {$task}" . ($dry ? ' [DRY RUN]' : ''), 'yellow');
$ok = $this->{$method}($dry, $dtz);
if ($ok === true) {
CLI::write("Task '{$task}' finished successfully.", 'green');
} else {
CLI::error("Task '{$task}' finished with warnings or no changes.");
}
} catch (\Throwable $e) {
CLI::error("Task '{$task}' failed: " . $e->getMessage());
} finally {
try {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
} catch (\Throwable $e) {
}
}
}
/** Accepts --name=value, --name value, -s value, or scans $params. */
private function getOptionString(string $name, array $params, ?string $short = null): ?string
{
$v = CLI::getOption($name);
if (is_string($v) && $v !== '') return trim($v);
if ($short) {
$v = CLI::getOption($short);
if (is_string($v) && $v !== '') return trim($v);
}
foreach ($params as $i => $p) {
if (strpos($p, "--{$name}=") === 0) return trim(substr($p, strlen($name) + 3));
if ($short && strpos($p, "-{$short}=") === 0) return trim(substr($p, strlen($short) + 2));
if ($p === "--{$name}" || ($short && $p === "-{$short}")) {
return $params[$i + 1] ?? null;
}
}
return null;
}
/** True if flag present as --name or -s (no value needed). */
private function hasFlag(string $name, array $params, ?string $short = null): bool
{
if (CLI::getOption($name) !== null) return true;
if ($short && CLI::getOption($short) !== null) return true;
foreach ($params as $p) {
if ($p === "--{$name}" || ($short && $p === "-{$short}")) return true;
}
return false;
}
/* ------------------------- Helpers ------------------------- */
protected function setConfig(string $key, string $value, bool $dry): bool
{
// show current value
$current = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
CLI::write("{$key}: current={$current}", 'light_gray');
CLI::write("Set {$key} = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
// ✅ use your model function
$ok = (bool) $this->configModel->setConfigValueByKey($key, $value);
// read-back to verify whats persisted
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
return $ok && ($after === $value);
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Events\Events;
use CodeIgniter\I18n\Time;
class DeleteInactiveUsers extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'users:delete-inactive-users';
protected $description = 'Delete users that are inactive and created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
public function run(array $params)
{
$db = \Config\Database::connect();
try {
CLI::write('Running deletion of inactive users...', 'yellow');
$cutoffTime = Time::now()->subMinutes(15)->toDateTimeString();
CLI::write("Cutoff time for deletion: $cutoffTime", 'blue');
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
// ─────────────────────────────────────────────────────
// 1Fetch inactive users older than 15 min
// ─────────────────────────────────────────────────────
$users = $db->table('users')
->select('id, firstname, lastname, email, created_at')
->where('status', 'Inactive')
->where('created_at <', $cutoffTime)
->get()
->getResultArray();
if (empty($users)) {
CLI::write('No inactive users found to delete.', 'yellow');
$this->purgeOrphanedUserRoles($db);
return;
}
CLI::write('Found ' . count($users) . ' users for deletion.', 'green');
// collect IDs
$userIds = array_column($users, 'id');
// ─────────────────────────────────────────────────────
// 2Transaction: delete parents → user_roles → users
// ─────────────────────────────────────────────────────
$db->transStart();
// 2-a Delete any secondary-parent rows tied to these users
$parentsBuilder = $db->table('parents');
$deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete();
CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue');
log_message('info', "Deleted $deletedParents rows from parents table.");
// 2-b Delete user_roles rows
$db->table('user_roles')->whereIn('user_id', $userIds)->delete();
CLI::write('Deleted related user_roles rows.', 'blue');
// 2-c Delete users
$db->table('users')->whereIn('id', $userIds)->delete();
CLI::write('Deleted users: ' . implode(', ', $userIds), 'green');
$db->transComplete();
if (!$db->transStatus()) {
CLI::write('Transaction failed; rolled back.', 'red');
return;
}
// Purge any stray user_role rows left behind (defensive)
$this->purgeOrphanedUserRoles($db);
$msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows.";
CLI::write($msg, 'green');
log_message('info', $msg);
} catch (\Throwable $e) {
CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red');
log_message('error', 'Error deleting inactive users: ' . $e->getMessage());
}
}
/**
* Remove user_roles rows that point to non-existent users.
*/
private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void
{
$orphaned = $db->table('user_roles')
->whereNotIn('user_id', function ($q) use ($db) {
$q->select('id')->from('users');
})
->delete();
if ($orphaned) {
CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green');
log_message('info', "Deleted $orphaned orphaned user_roles rows.");
} else {
CLI::write('No orphaned user_roles rows found.', 'yellow');
}
}
}
@@ -1,93 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
class RecalculateAttendance extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:recalculate-summary';
protected $description = 'Recalculates the attendance summary records (total absences, etc.) from the raw attendance data.';
public function run(array $params)
{
$db = Database::connect();
CLI::write('Starting attendance summary recalculation...', 'yellow');
// 1. Truncate the attendance_record table
try {
$db->table('attendance_record')->truncate();
CLI::write('Successfully truncated attendance_record table.', 'green');
} catch (\Throwable $e) {
CLI::error('Failed to truncate attendance_record table: ' . $e->getMessage());
return;
}
// 2. Get all attendance data
$attendanceData = $db->table('attendance_data')
->orderBy('student_id', 'ASC')
->orderBy('school_year', 'ASC')
->orderBy('semester', 'ASC')
->get()->getResultArray();
if (empty($attendanceData)) {
CLI::write('No attendance data found to process.', 'yellow');
return;
}
$summary = [];
// 3. Process the data
foreach ($attendanceData as $row) {
$studentId = $row['student_id'];
$schoolYear = $row['school_year'];
$semester = $row['semester'];
$status = strtolower($row['status']);
$key = "{$studentId}-{$schoolYear}-{$semester}";
if (!isset($summary[$key])) {
$summary[$key] = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'semester' => $semester,
'class_section_id' => $row['class_section_id'],
'school_id' => $row['school_id'],
'total_presence' => 0,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 0,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
}
$summary[$key]['total_attendance']++;
if ($status === 'present') {
$summary[$key]['total_presence']++;
} elseif ($status === 'absent') {
$summary[$key]['total_absence']++;
} elseif ($status === 'late') {
$summary[$key]['total_late']++;
}
}
// 4. Insert the new summary records
if (!empty($summary)) {
$builder = $db->table('attendance_record');
try {
$builder->insertBatch(array_values($summary));
CLI::write('Successfully inserted ' . count($summary) . ' summary records.', 'green');
} catch (\Throwable $e) {
CLI::error('Failed to insert summary records: ' . $e->getMessage());
return;
}
}
CLI::write('Attendance summary recalculation finished.', 'green');
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\AttendanceDataModel;
use App\Models\UserModel;
use App\Services\NotificationService;
class SendAbsenteesSummary extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:absentees-summary';
protected $description = 'Sends daily attendance summaries to parents.';
public function run(array $params)
{
$attendanceModel = new AttendanceDataModel();
$userModel = new UserModel();
// Fetch todays absent records (replace with your logic)
$absentRecords = $attendanceModel->getTodayAbsentees();
foreach ($absentRecords as $record) {
// Get parent user ID
$parent = $userModel->find($record['parent_id']);
if (!$parent) continue;
NotificationService::toUser(
$parent['id'],
'Daily Attendance Update',
"{$record['student_name']} was marked absent on {$record['date']}.",
['in_app', 'email']
);
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
}
CLI::write("Attendance summary completed.", 'green');
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\AttendanceDataModel;
use App\Models\UserModel;
use App\Services\NotificationService;
class SendLatesSummary extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:lates-summary';
protected $description = 'Sends daily attendance summaries to parents.';
public function run(array $params)
{
$attendanceModel = new AttendanceDataModel();
$userModel = new UserModel();
// Fetch todays late records (replace with your logic)
$lateRecords = $attendanceModel->getTodayLates();
foreach ($lateRecords as $record) {
// Get parent user ID
$parent = $userModel->find($record['parent_id']);
if (!$parent) continue;
NotificationService::toUser(
$parent['id'],
'Daily Attendance Update',
"{$record['student_name']} was marked late on {$record['date']}.",
['in_app', 'email']
);
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
}
CLI::write("Attendance summary completed.", 'green');
}
}
@@ -1,372 +0,0 @@
<?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 ?: [];
}
}
@@ -1,150 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\PaymentNotificationLogModel;
use App\Models\UserModel;
use App\Models\FamilyGuardianModel;
use App\Services\EmailService;
class SendTestPaymentNotification extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:send-test';
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
public function run(array $params)
{
$email = CLI::getOption('email');
$type = CLI::getOption('type') ?? 'no_payment';
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
CLI::write('Usage: php spark payments:send-test --email=addr@example.com [--type=no_payment|installment]', 'yellow');
return;
}
$config = new ConfigurationModel();
$user = new UserModel();
$invoice= new InvoiceModel();
$logs = new PaymentNotificationLogModel();
$fam = new FamilyGuardianModel();
$mailer = new EmailService();
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$year = (int)$now->format('Y');
$month= (int)$now->format('n');
$schoolYear = (string) ($config->getConfig('school_year') ?? $year);
$userRow = $user->where('email', $email)->first();
$parentId = (int)($userRow['id'] ?? 0);
$invRows = $parentId ? $invoice->getAllInvoicesByUserIds([$parentId], $schoolYear) : [];
$totalBalance = 0.0; $latestInvoiceId = null;
foreach ($invRows as $ir) {
$totalBalance += (float)($ir['balance'] ?? 0);
if ($latestInvoiceId === null) $latestInvoiceId = (int)($ir['id'] ?? 0);
}
// Secondary guardian if available
$ccEmail = null;
if ($parentId) {
$row = $fam->where('user_id', $parentId)->first();
if ($row && !empty($row['family_id'])) {
$others = $fam->where('family_id', (int)$row['family_id'])
->where('user_id !=', $parentId)
->where('receive_emails', 1)->findAll();
foreach ($others as $g) {
$ccEmail = $user->select('email')->find((int)$g['user_id'])['email'] ?? null;
if ($ccEmail && filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) break;
$ccEmail = null;
}
}
}
// Compose body (reuse layout)
$parentName = trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? ''));
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$balanceFmt = '$' . number_format($totalBalance, 2);
$intro = ($type === 'no_payment')
? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."
: "This is your monthly installment reminder for {$schoolYear}.";
$bodyHtml = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<p>
As a friendly reminder, our tuition installments are due at the beginning of each month.
You can review your invoice and payment options by logging in to your parent portal.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
// Compute remaining and max installments similar to Manual Payment UI
$installmentEndRaw = (string)$config->getConfig('installment_date');
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
$remMonths = 0;
if ($end) {
$y = (int)$end->format('Y') - (int)$today->format('Y');
$m = (int)$end->format('n') - (int)$today->format('n');
$remMonths = $y * 12 + $m;
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
if ($remMonths < 0) $remMonths = 0;
}
$remainingInst = max(1, $remMonths);
$maxInst = max(($remMonths ?: 0), ($totalBalance > 0 ? 2 : 0));
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($totalBalance / $remainingInst) : 0, 2);
$bodyHtml .= "<ul>"
. "<li><strong>Remaining installments:</strong> {$remainingInst}</li>"
. "<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>"
. "<li><strong>Maximum installments available:</strong> {$maxInst}</li>"
. "</ul>";
$body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]);
$ok = $mailer->send($email, $subject, $body, 'finance');
if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) {
$mailer->send($ccEmail, $subject, $body, 'finance');
}
$existing = $logs->where('parent_id', $parentId)
->where('period_year', $year)
->where('period_month', $month)
->where('type', $type)
->first();
$payload = [
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $email,
'cc_email' => $ccEmail,
'head_fa_notified' => 0,
'subject' => $subject,
'body' => $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Email send failed (see logs)',
'balance_snapshot' => $totalBalance,
'sent_at' => $now->format('Y-m-d H:i:s'),
];
if ($existing) $logs->update($existing['id'], $payload); else $logs->insert($payload);
CLI::write(($ok ? 'Sent' : 'Failed') . " test reminder to {$email}", $ok ? 'green' : 'red');
}
}
-232
View File
@@ -1,232 +0,0 @@
<?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();
}
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Events;
class DeleteUnverifiedUser
{
public $userId;
public function __construct($userId)
{
$this->userId = $userId;
}
public static function deleteAfterTimeout($userId)
{
$model = new self();
// Define the timeout period
$timeoutPeriod = 24 * 60 * 60; // 1 day in seconds
// Retrieve the user's record
$user = $model->find($userId);
if ($user) {
// Get the most recent timestamp to compare with the current time
$lastActivityTime = strtotime($user['updated_at'] ?? $user['created_at']);
// Calculate the time difference
$timeSinceLastActivity = time() - $lastActivityTime;
// Check if the time difference exceeds the timeout period
if ($timeSinceLastActivity > $timeoutPeriod) {
// Delete the user record
return $model->delete($userId);
} else {
// Timeout period has not yet passed
return false; // Or you can return a message indicating no action taken
}
}
// Return false if the user is not found
return false;
}
}
View File
-28
View File
@@ -1,28 +0,0 @@
<?php
use App\Models\UserRoleModel;
use App\Models\RolePermissionModel;
use App\Models\PermissionModel;
function has_permission($userId, $permissionName)
{
$userRoleModel = new UserRoleModel();
$rolePermissionModel = new RolePermissionModel();
$permissionModel = new PermissionModel();
$permission = $permissionModel->where('name', $permissionName)->first();
if (!$permission) {
return false;
}
$permissionId = $permission['id'];
$roles = $userRoleModel->where('user_id', $userId)->findAll();
foreach ($roles as $role) {
$permissions = $rolePermissionModel->where('role_id', $role['role_id'])->where('permission_id', $permissionId)->findAll();
if (!empty($permissions)) {
return true;
}
}
return false;
}
View File
-18
View File
@@ -1,18 +0,0 @@
<?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');
}
}
-86
View File
@@ -1,86 +0,0 @@
<?php
if (!function_exists('base64url_encode')) {
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
if (!function_exists('jwt_encode')) {
/**
* Minimal HS256 JWT encoder.
*
* @param array $payload
* @param string $secret
* @param string $alg Only HS256 supported here
* @return string JWT string
*/
function jwt_encode(array $payload, string $secret, string $alg = 'HS256'): string
{
$header = ['typ' => 'JWT', 'alg' => $alg];
$segments = [];
$segments[] = base64url_encode(json_encode($header, JSON_UNESCAPED_SLASHES));
$segments[] = base64url_encode(json_encode($payload, JSON_UNESCAPED_SLASHES));
$signingInput = implode('.', $segments);
switch ($alg) {
case 'HS256':
$signature = hash_hmac('sha256', $signingInput, $secret, true);
break;
default:
throw new \InvalidArgumentException('Unsupported JWT alg: ' . $alg);
}
$segments[] = base64url_encode($signature);
return implode('.', $segments);
}
}
if (!function_exists('base64url_decode')) {
function base64url_decode(string $data): string
{
$padding = strlen($data) % 4;
if ($padding > 0) {
$data .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($data, '-_', '+/'));
}
}
if (!function_exists('jwt_decode')) {
function jwt_decode(string $token, string $secret, string $alg = 'HS256'): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header64, $payload64, $signature64] = $parts;
$header = json_decode(base64url_decode($header64), true);
$payload = json_decode(base64url_decode($payload64), true);
$signature = base64url_decode($signature64);
if (!is_array($header) || !is_array($payload)) {
return null;
}
$signingInput = $header64 . '.' . $payload64;
switch ($header['alg'] ?? $alg) {
case 'HS256':
$expected = hash_hmac('sha256', $signingInput, $secret, true);
break;
default:
return null;
}
if (!hash_equals($expected, $signature)) {
return null;
}
return $payload;
}
}
-39
View File
@@ -1,39 +0,0 @@
<?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);
}
}
@@ -1,27 +0,0 @@
<?php
use App\Models\ConfigurationModel;
if (!function_exists('selected_teacher_semester')) {
function selected_teacher_semester(): string
{
$session = session();
$selected = trim((string) ($session->get('teacher_scores_selected_semester') ?? ''));
if ($selected !== '') {
return ucfirst(strtolower($selected));
}
$fallback = trim((string) ($session->get('semester') ?? ''));
if ($fallback !== '') {
return ucfirst(strtolower($fallback));
}
$config = new ConfigurationModel();
$configSemester = trim((string) ($config->getConfig('semester') ?? ''));
if ($configSemester !== '') {
return ucfirst(strtolower($configSemester));
}
return 'Fall';
}
}
-75
View File
@@ -1,75 +0,0 @@
<?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);
}
}
@@ -1,17 +0,0 @@
<?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;
}
+3
View File
@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('users:delete-unverified')->everyFifteenMinutes();
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Console;
use App\Events\DeleteUnverifiedUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class DeleteUnverifiedUsersCommandTest extends TestCase
{
use RefreshDatabase;
public function test_command_dispatches_event_for_expired_user(): void
{
DB::table('users')->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;
});
}
}
@@ -0,0 +1,23 @@
<?php
namespace Tests\Unit\Listeners;
use App\Events\DeleteUnverifiedUser;
use App\Listeners\DeleteUnverifiedUserListener;
use App\Services\Users\DeleteUnverifiedUserService;
use Mockery;
use Tests\TestCase;
class DeleteUnverifiedUserListenerTest extends TestCase
{
public function test_listener_invokes_service(): void
{
$service = Mockery::mock(DeleteUnverifiedUserService::class);
$service->shouldReceive('deleteAfterTimeout')->once()->with(15)->andReturn(['ok' => true]);
$listener = new DeleteUnverifiedUserListener($service);
$listener->handle(new DeleteUnverifiedUser(15));
$this->assertTrue(true);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit\Services\Attendance;
use App\Services\Attendance\AttendanceCommentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AttendanceCommentServiceTest extends TestCase
{
use RefreshDatabase;
public function test_comment_from_score_uses_template_and_name(): void
{
DB::table('attendance_comment_template')->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);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Unit\Services\Auth;
use App\Services\Auth\PermissionCheckService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class PermissionCheckServiceTest extends TestCase
{
use RefreshDatabase;
public function test_has_permission_returns_true_when_assigned(): void
{
$permissionId = DB::table('permissions')->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'));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Unit\Services\School;
use App\Services\School\SemesterSelectionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SemesterSelectionServiceTest extends TestCase
{
use RefreshDatabase;
public function test_selected_teacher_semester_prefers_session_value(): void
{
session()->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());
}
}
@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit\Services\Scores;
use App\Models\AttendanceRecord;
use App\Models\CalendarEvent;
use App\Models\Configuration;
use App\Services\Scores\AttendanceCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AttendanceCalculatorTest extends TestCase
{
use RefreshDatabase;
public function test_calculate_uses_configured_days(): void
{
DB::table('configuration')->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']);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Services\Scores;
use App\Models\Homework;
use App\Services\Scores\HomeworkCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class HomeworkCalculatorTest extends TestCase
{
use RefreshDatabase;
public function test_calculate_returns_average_score(): void
{
DB::table('homework')->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']);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Services\Scores;
use App\Models\Project;
use App\Services\Scores\ProjectCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ProjectCalculatorTest extends TestCase
{
use RefreshDatabase;
public function test_calculate_returns_average_score(): void
{
DB::table('project')->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']);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Tests\Unit\Services\Scores;
use App\Services\Scores\QuizCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class QuizCalculatorTest extends TestCase
{
use RefreshDatabase;
public function test_calculate_returns_average_score(): void
{
DB::table('quiz')->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']);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Tests\Unit\Services\Security;
use App\Services\Security\Pbkdf2Hasher;
use Tests\TestCase;
class Pbkdf2HasherTest extends TestCase
{
public function test_hash_and_verify(): void
{
$hasher = new Pbkdf2Hasher();
$hash = $hasher->hash('secret');
$this->assertTrue($hasher->verify('secret', $hash));
$this->assertFalse($hasher->verify('wrong', $hash));
}
}
@@ -0,0 +1,26 @@
<?php
namespace Tests\Unit\Services\System;
use App\Services\System\GlobalConfigService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class GlobalConfigServiceTest extends TestCase
{
use RefreshDatabase;
public function test_get_semester_and_school_year(): void
{
DB::table('configuration')->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());
}
}
@@ -0,0 +1,18 @@
<?php
namespace Tests\Unit\Services\System;
use App\Services\System\TimeService;
use Tests\TestCase;
class TimeServiceTest extends TestCase
{
public function test_to_utc_and_to_local_round_trip(): void
{
$service = new TimeService();
$utc = $service->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);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Tests\Unit\Services\Users;
use App\Services\School\AccountEventService;
use App\Services\Users\DeleteUnverifiedUserService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class DeleteUnverifiedUserServiceTest extends TestCase
{
use RefreshDatabase;
public function test_delete_after_timeout_deletes_user(): void
{
DB::table('users')->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]);
}
}