add services logic
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
+38
-17
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
*/
|
||||
@@ -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 what’s persisted
|
||||
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
|
||||
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
|
||||
|
||||
return $ok && ($after === $value);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 today’s absent records (replace with your logic)
|
||||
$absentRecords = $attendanceModel->getTodayAbsentees();
|
||||
|
||||
foreach ($absentRecords as $record) {
|
||||
// Get parent user ID
|
||||
$parent = $userModel->find($record['parent_id']);
|
||||
if (!$parent) continue;
|
||||
|
||||
NotificationService::toUser(
|
||||
$parent['id'],
|
||||
'Daily Attendance Update',
|
||||
"{$record['student_name']} was marked absent on {$record['date']}.",
|
||||
['in_app', 'email']
|
||||
);
|
||||
|
||||
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Attendance summary completed.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -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 today’s late records (replace with your logic)
|
||||
$lateRecords = $attendanceModel->getTodayLates();
|
||||
|
||||
foreach ($lateRecords as $record) {
|
||||
// Get parent user ID
|
||||
$parent = $userModel->find($record['parent_id']);
|
||||
if (!$parent) continue;
|
||||
|
||||
NotificationService::toUser(
|
||||
$parent['id'],
|
||||
'Daily Attendance Update',
|
||||
"{$record['student_name']} was marked late on {$record['date']}.",
|
||||
['in_app', 'email']
|
||||
);
|
||||
|
||||
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Attendance summary completed.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user