add notifications logic and add support of both JWT and Sanctum

This commit is contained in:
root
2026-03-11 01:20:31 -04:00
parent f6be51576c
commit 182036cc41
141 changed files with 8685 additions and 648 deletions
@@ -0,0 +1,46 @@
<?php
namespace App\Commands;
use App\Libraries\AttendanceAutoPublish as AutoPublishLib;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class AttendanceAutoPublishCommand extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:auto-publish';
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
public function run(array $params)
{
$db = db_connect();
$zone = AutoPublishLib::tz();
$now = new \DateTimeImmutable('now', $zone);
$nowStr = $now->format('Y-m-d H:i:s');
$cutoffDate = AutoPublishLib::secondSundayBackwardDate($now);
$builder = $db->table('attendance_day');
$builder->where('status', 'submitted')
->groupStart()
->where('auto_publish_at <=', $nowStr)
->orGroupStart()
->where('auto_publish_at IS NULL', null, false)
->where('date <=', $cutoffDate)
->groupEnd()
->groupEnd();
$count = $builder->countAllResults(false); // keep the WHEREs
if ($count > 0) {
$builder->update([
'status' => 'published',
'published_by' => 0, // system
'published_at' => $nowStr,
'updated_at' => $nowStr,
]);
}
CLI::write("Auto-publish checked at {$nowStr} (TZ: {$zone->getName()}). Published rows: {$count}.");
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\UserModel;
use App\Services\NotificationService;
class CheckMissedPayments extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:check-missed';
protected $description = 'Checks for users who missed payments and sends reminders.';
public function run(array $params)
{
$userModel = new UserModel();
// Fetch users who missed payment (you need to implement this query)
$missedUsers = $userModel->getUsersWithMissedPayments();
foreach ($missedUsers as $user) {
NotificationService::toUser(
$user['id'],
'Payment Missed',
'You have a missed payment. Please pay to avoid penalty.',
['in_app', 'email', 'sms']
);
CLI::write("Reminder sent to {$user['email']}", 'yellow');
}
CLI::write("Finished checking missed payments.", 'green');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\NotificationModel;
class CleanupExpiredNotifications extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'notifications:cleanup';
protected $description = 'Deletes expired notifications from the database.';
public function run(array $params)
{
$model = new NotificationModel();
// Fetch expired notifications
$expired = $model->where('expires_at IS NOT NULL')
->where('expires_at < NOW()')
->findAll();
if (empty($expired)) {
CLI::write(" No expired notifications found to soft delete.", 'yellow');
return;
}
$count = 0;
foreach ($expired as $note) {
$model->delete($note['id']); // Soft delete
$count++;
}
CLI::write("✅ Soft-deleted {$count} expired notifications.", 'green');
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PasswordResetRequestModel;
use CodeIgniter\I18n\Time;
class CleanupPasswordResets extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'cleanup:password-resets';
protected $description = 'Delete password reset requests older than 30 days';
public function run(array $params)
{
$model = new PasswordResetRequestModel();
$threshold = Time::now()->subDays(30)->toDateTimeString();
$count = $model->where('requested_at <', $threshold)->delete();
CLI::write("Deleted $count old password reset request(s).", 'green');
}
}
/*
Add this cron job to run every 24hrs
0 0 * * * /usr/bin/php /path/to/project/public/index.php cleanup:password-resets >> /path/to/project/writable/logs/cleanup.log 2>&1
*/
+232
View File
@@ -0,0 +1,232 @@
<?php
namespace App\Commands;
use App\Models\ConfigurationModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use DateTime;
use DateTimeZone;
class ConfigUpdate extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'config:update';
protected $description = 'Run a configuration update task (weekly cron).';
protected $arguments = [];
protected $usage = 'php spark config:update [task] [--task task|-t task] [--dry] [--force] [--tz=<timezone>]';
protected $options = [
'task' => 'Task name (or pass as first positional arg)',
't' => 'Short form of --task',
'dry' => 'Dry run (no DB writes)',
'force' => 'Ignore lock and run anyway',
'tz' => 'Timezone (default: configured school timezone)',
];
/** @var ConfigurationModel */
protected $configModel;
/** Map of available task names -> handler methods. */
protected array $tasks = [
'update_date_age_reference' => 'taskUpdateDateAgeReference',
'enable_attendance_on' => 'taskEnableAttendanceOn',
'enable_attendance_off' => 'taskEnableAttendanceOff',
'set_semester_spring' => 'taskSetSemesterSpring',
'set_semester_fall' => 'taskSetSemesterFall',
];
/**
* Set enable_attendance = 1
*/
protected function taskEnableAttendanceOn(bool $dry, DateTimeZone $tz): bool
{
return $this->setConfig('enable_attendance', '1', $dry);
}
/**
* Set enable_attendance = 0
*/
protected function taskEnableAttendanceOff(bool $dry, DateTimeZone $tz): bool
{
return $this->setConfig('enable_attendance', '0', $dry);
}
protected function taskUpdateDateAgeReference(bool $dry, DateTimeZone $tz): bool
{
$now = new DateTime('now', $tz);
$isJune1 = ($now->format('n') === '6' && $now->format('j') === '1');
$forced = (CLI::getOption('force') !== null);
if (!$isJune1 && !$forced) {
CLI::write(
"Today is {$now->format('Y-m-d')} (not June 1) — skipping. Use --force to override.",
'yellow'
);
return true; // no-op, not an error
}
$year = (int) $now->format('Y');
$value = sprintf('%04d-12-31', $year);
CLI::write("Set date_age_reference = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
// Use your model method
$ok = (bool) $this->configModel->setConfigValueByKey('date_age_reference', $value);
if ($ok) {
CLI::write("date_age_reference updated to {$value}", 'green');
} else {
CLI::error("Failed to update date_age_reference");
}
return $ok;
}
protected function taskSetSemesterSpring(bool $dry, DateTimeZone $tz): bool
{
$now = new DateTime('now', $tz);
$isFeb1 = ($now->format('n') === '2' && $now->format('j') === '1');
$forced = (CLI::getOption('force') !== null);
if (!$isFeb1 && !$forced) {
CLI::write(
"Today is {$now->format('Y-m-d')} (not Feb 1) — skipping. Use --force to override.",
'yellow'
);
return true; // no-op is success
}
CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring');
}
protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool
{
$now = new DateTime('now', $tz);
$isJun1 = ($now->format('n') === '6' && $now->format('j') === '1');
$forced = (CLI::getOption('force') !== null);
if (!$isJun1 && !$forced) {
CLI::write(
"Today is {$now->format('Y-m-d')} (not Jun 1) — skipping. Use --force to override.",
'yellow'
);
return true;
}
CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall');
}
public function run(array $params)
{
$this->configModel = model(ConfigurationModel::class);
$tz = $this->getOptionString('tz', $params) ?? ((string)(config('School')->attendance['timezone'] ?? 'UTC'));
$task = $this->getOptionString('task', $params, 't');
// Fallback: first positional arg (php spark config:update enable_attendance_on)
if (!$task && !empty($params) && strpos($params[0], '-') !== 0) {
$task = trim($params[0]);
}
$dry = $this->hasFlag('dry', $params);
$force = $this->hasFlag('force', $params);
$dtz = new DateTimeZone($tz);
if ($task === '' || !isset($this->tasks[$task])) {
CLI::error('Invalid or missing --task. Available: ' . implode(', ', array_keys($this->tasks)));
return;
}
// Per-task lock
$lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "ci4_config_update_{$task}.lock";
$fp = @fopen($lockFile, 'c+');
if (!$fp) {
CLI::error("Unable to open lock file: $lockFile");
return;
}
if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) {
CLI::error("Task '$task' is already running (lock held). Use --force to override.");
fclose($fp);
return;
}
try {
$method = $this->tasks[$task];
CLI::write("Running task: {$task}" . ($dry ? ' [DRY RUN]' : ''), 'yellow');
$ok = $this->{$method}($dry, $dtz);
if ($ok === true) {
CLI::write("Task '{$task}' finished successfully.", 'green');
} else {
CLI::error("Task '{$task}' finished with warnings or no changes.");
}
} catch (\Throwable $e) {
CLI::error("Task '{$task}' failed: " . $e->getMessage());
} finally {
try {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
} catch (\Throwable $e) {
}
}
}
/** Accepts --name=value, --name value, -s value, or scans $params. */
private function getOptionString(string $name, array $params, ?string $short = null): ?string
{
$v = CLI::getOption($name);
if (is_string($v) && $v !== '') return trim($v);
if ($short) {
$v = CLI::getOption($short);
if (is_string($v) && $v !== '') return trim($v);
}
foreach ($params as $i => $p) {
if (strpos($p, "--{$name}=") === 0) return trim(substr($p, strlen($name) + 3));
if ($short && strpos($p, "-{$short}=") === 0) return trim(substr($p, strlen($short) + 2));
if ($p === "--{$name}" || ($short && $p === "-{$short}")) {
return $params[$i + 1] ?? null;
}
}
return null;
}
/** True if flag present as --name or -s (no value needed). */
private function hasFlag(string $name, array $params, ?string $short = null): bool
{
if (CLI::getOption($name) !== null) return true;
if ($short && CLI::getOption($short) !== null) return true;
foreach ($params as $p) {
if ($p === "--{$name}" || ($short && $p === "-{$short}")) return true;
}
return false;
}
/* ------------------------- Helpers ------------------------- */
protected function setConfig(string $key, string $value, bool $dry): bool
{
// show current value
$current = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
CLI::write("{$key}: current={$current}", 'light_gray');
CLI::write("Set {$key} = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray');
if ($dry) return true;
// ✅ use your model function
$ok = (bool) $this->configModel->setConfigValueByKey($key, $value);
// read-back to verify whats persisted
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
return $ok && ($after === $value);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Events\Events;
use CodeIgniter\I18n\Time;
class DeleteInactiveUsers extends BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'users:delete-inactive-users';
protected $description = 'Delete users that are inactive and created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
public function run(array $params)
{
$db = \Config\Database::connect();
try {
CLI::write('Running deletion of inactive users...', 'yellow');
$cutoffTime = Time::now()->subMinutes(15)->toDateTimeString();
CLI::write("Cutoff time for deletion: $cutoffTime", 'blue');
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
// ─────────────────────────────────────────────────────
// 1Fetch inactive users older than 15 min
// ─────────────────────────────────────────────────────
$users = $db->table('users')
->select('id, firstname, lastname, email, created_at')
->where('status', 'Inactive')
->where('created_at <', $cutoffTime)
->get()
->getResultArray();
if (empty($users)) {
CLI::write('No inactive users found to delete.', 'yellow');
$this->purgeOrphanedUserRoles($db);
return;
}
CLI::write('Found ' . count($users) . ' users for deletion.', 'green');
// collect IDs
$userIds = array_column($users, 'id');
// ─────────────────────────────────────────────────────
// 2Transaction: delete parents → user_roles → users
// ─────────────────────────────────────────────────────
$db->transStart();
// 2-a Delete any secondary-parent rows tied to these users
$parentsBuilder = $db->table('parents');
$deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete();
CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue');
log_message('info', "Deleted $deletedParents rows from parents table.");
// 2-b Delete user_roles rows
$db->table('user_roles')->whereIn('user_id', $userIds)->delete();
CLI::write('Deleted related user_roles rows.', 'blue');
// 2-c Delete users
$db->table('users')->whereIn('id', $userIds)->delete();
CLI::write('Deleted users: ' . implode(', ', $userIds), 'green');
$db->transComplete();
if (!$db->transStatus()) {
CLI::write('Transaction failed; rolled back.', 'red');
return;
}
// Purge any stray user_role rows left behind (defensive)
$this->purgeOrphanedUserRoles($db);
$msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows.";
CLI::write($msg, 'green');
log_message('info', $msg);
} catch (\Throwable $e) {
CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red');
log_message('error', 'Error deleting inactive users: ' . $e->getMessage());
}
}
/**
* Remove user_roles rows that point to non-existent users.
*/
private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void
{
$orphaned = $db->table('user_roles')
->whereNotIn('user_id', function ($q) use ($db) {
$q->select('id')->from('users');
})
->delete();
if ($orphaned) {
CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green');
log_message('info', "Deleted $orphaned orphaned user_roles rows.");
} else {
CLI::write('No orphaned user_roles rows found.', 'yellow');
}
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
class RecalculateAttendance extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:recalculate-summary';
protected $description = 'Recalculates the attendance summary records (total absences, etc.) from the raw attendance data.';
public function run(array $params)
{
$db = Database::connect();
CLI::write('Starting attendance summary recalculation...', 'yellow');
// 1. Truncate the attendance_record table
try {
$db->table('attendance_record')->truncate();
CLI::write('Successfully truncated attendance_record table.', 'green');
} catch (\Throwable $e) {
CLI::error('Failed to truncate attendance_record table: ' . $e->getMessage());
return;
}
// 2. Get all attendance data
$attendanceData = $db->table('attendance_data')
->orderBy('student_id', 'ASC')
->orderBy('school_year', 'ASC')
->orderBy('semester', 'ASC')
->get()->getResultArray();
if (empty($attendanceData)) {
CLI::write('No attendance data found to process.', 'yellow');
return;
}
$summary = [];
// 3. Process the data
foreach ($attendanceData as $row) {
$studentId = $row['student_id'];
$schoolYear = $row['school_year'];
$semester = $row['semester'];
$status = strtolower($row['status']);
$key = "{$studentId}-{$schoolYear}-{$semester}";
if (!isset($summary[$key])) {
$summary[$key] = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'semester' => $semester,
'class_section_id' => $row['class_section_id'],
'school_id' => $row['school_id'],
'total_presence' => 0,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 0,
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
}
$summary[$key]['total_attendance']++;
if ($status === 'present') {
$summary[$key]['total_presence']++;
} elseif ($status === 'absent') {
$summary[$key]['total_absence']++;
} elseif ($status === 'late') {
$summary[$key]['total_late']++;
}
}
// 4. Insert the new summary records
if (!empty($summary)) {
$builder = $db->table('attendance_record');
try {
$builder->insertBatch(array_values($summary));
CLI::write('Successfully inserted ' . count($summary) . ' summary records.', 'green');
} catch (\Throwable $e) {
CLI::error('Failed to insert summary records: ' . $e->getMessage());
return;
}
}
CLI::write('Attendance summary recalculation finished.', 'green');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\AttendanceDataModel;
use App\Models\UserModel;
use App\Services\NotificationService;
class SendAbsenteesSummary extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:absentees-summary';
protected $description = 'Sends daily attendance summaries to parents.';
public function run(array $params)
{
$attendanceModel = new AttendanceDataModel();
$userModel = new UserModel();
// Fetch todays absent records (replace with your logic)
$absentRecords = $attendanceModel->getTodayAbsentees();
foreach ($absentRecords as $record) {
// Get parent user ID
$parent = $userModel->find($record['parent_id']);
if (!$parent) continue;
NotificationService::toUser(
$parent['id'],
'Daily Attendance Update',
"{$record['student_name']} was marked absent on {$record['date']}.",
['in_app', 'email']
);
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
}
CLI::write("Attendance summary completed.", 'green');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\AttendanceDataModel;
use App\Models\UserModel;
use App\Services\NotificationService;
class SendLatesSummary extends BaseCommand
{
protected $group = 'Attendance';
protected $name = 'attendance:lates-summary';
protected $description = 'Sends daily attendance summaries to parents.';
public function run(array $params)
{
$attendanceModel = new AttendanceDataModel();
$userModel = new UserModel();
// Fetch todays late records (replace with your logic)
$lateRecords = $attendanceModel->getTodayLates();
foreach ($lateRecords as $record) {
// Get parent user ID
$parent = $userModel->find($record['parent_id']);
if (!$parent) continue;
NotificationService::toUser(
$parent['id'],
'Daily Attendance Update',
"{$record['student_name']} was marked late on {$record['date']}.",
['in_app', 'email']
);
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
}
CLI::write("Attendance summary completed.", 'green');
}
}
@@ -0,0 +1,372 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\PaymentNotificationLogModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Models\FamilyGuardianModel;
use App\Services\EmailService;
use App\Services\NotificationService;
class SendMonthlyPaymentNotifications extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:monthly-reminder';
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
protected EmailService $emailService;
protected ConfigurationModel $configModel;
protected InvoiceModel $invoiceModel;
protected PaymentModel $paymentModel;
protected PaymentNotificationLogModel $logModel;
protected UserModel $userModel;
protected FamilyGuardianModel $familyGuardianModel;
public function run(array $params)
{
// Lazy init to avoid BaseCommand constructor issues during discovery
$this->emailService = new EmailService();
$this->configModel = new ConfigurationModel();
$this->invoiceModel = new InvoiceModel();
$this->paymentModel = new PaymentModel();
$this->logModel = new PaymentNotificationLogModel();
$this->userModel = new UserModel();
$this->familyGuardianModel = new FamilyGuardianModel();
// Parse params: --force, --email=addr, --type=no_payment|installment
$force = false;
$targetEmail = null;
$targetType = null;
foreach ($params as $p) {
if ($p === '--force') { $force = true; continue; }
if (strpos($p, '--email=') === 0) { $targetEmail = trim(substr($p, 8)); continue; }
if (strpos($p, '--type=') === 0) { $targetType = trim(substr($p, 7)); continue; }
}
// Also support CI4 options parser
$optEmail = CLI::getOption('email');
if ($optEmail !== null) $targetEmail = $optEmail;
$optType = CLI::getOption('type');
if ($optType !== null) $targetType = $optType;
if (CLI::getOption('force') !== null) $force = true;
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$year = (int) $now->format('Y');
$month = (int) $now->format('n');
if (!$targetEmail && !$force && !$this->isFirstSaturday($now)) {
CLI::write('Not the first Saturday of the month. Use --force to override.', 'yellow');
return;
}
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? $year);
// Targeted test mode: only send to a specific email
if ($targetEmail) {
if (!filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) {
CLI::write('Invalid --email provided', 'red');
return;
}
$userRow = $this->userModel->where('email', $targetEmail)->first();
$parentId = (int)($userRow['id'] ?? 0);
$invRows = $parentId ? $this->invoiceModel->getAllInvoicesByUserIds([$parentId], $schoolYear) : [];
$totalBalance = 0.0;
$latestInvoiceId = null;
foreach ($invRows as $ir) {
$totalBalance += (float)($ir['balance'] ?? 0);
if ($latestInvoiceId === null) {
$latestInvoiceId = (int) ($ir['id'] ?? 0);
}
}
$type = in_array($targetType, ['no_payment','installment'], true) ? $targetType : 'no_payment';
$ccEmail = $parentId ? $this->getSecondaryGuardianEmail($parentId) : null;
[$subject, $body] = $this->composeEmail($parentId ?: 0, $schoolYear, $type, $totalBalance, $now);
$sentOk = $this->emailService->send($targetEmail, $subject, $body, 'finance');
if ($sentOk && $ccEmail && strcasecmp($ccEmail, $targetEmail) !== 0) {
$this->emailService->send($ccEmail, $subject, $body, 'finance');
}
// Upsert log for this month
$existing = $this->logModel->where('parent_id', $parentId)
->where('period_year', $year)
->where('period_month', $month)
->where('type', $type)
->first();
$payload = [
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $targetEmail,
'cc_email' => $ccEmail,
'head_fa_notified' => 0,
'subject' => $subject,
'body' => $body,
'status' => $sentOk ? 'sent' : 'failed',
'error_message' => $sentOk ? null : 'Email send failed (see logs)',
'balance_snapshot' => $totalBalance,
'sent_at' => $now->format('Y-m-d H:i:s'),
];
if ($existing) {
$this->logModel->update($existing['id'], $payload);
} else {
$this->logModel->insert($payload);
}
CLI::write(($sentOk ? 'Sent' : 'Failed') . " test reminder to {$targetEmail}", $sentOk ? 'green' : 'red');
return;
}
// Get all parents with invoices for the current school year
$db = \Config\Database::connect();
$rows = $db->table('invoices')
->select('parent_id')
->where('school_year', $schoolYear)
->groupBy('parent_id')
->get()
->getResultArray();
$parentIds = array_values(array_unique(array_map(static fn($r) => (int) $r['parent_id'], $rows)));
if (empty($parentIds)) {
CLI::write('No invoices found for current school year. Nothing to do.', 'yellow');
return;
}
$sentCount = 0;
foreach ($parentIds as $parentId) {
// Compute total balance across invoices for this year
$invRows = $this->invoiceModel->getAllInvoicesByUserIds([$parentId], $schoolYear);
$totalBalance = 0.0;
$latestInvoiceId = null;
foreach ($invRows as $ir) {
$totalBalance += (float)($ir['balance'] ?? 0);
if ($latestInvoiceId === null) {
$latestInvoiceId = (int) ($ir['id'] ?? 0);
}
}
if ($totalBalance <= 0.0) {
continue; // up to date
}
// Determine if parent has any payments this school year
$hasPayments = $db->table('payments')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->countAllResults() > 0;
$type = $hasPayments ? 'installment' : 'no_payment';
// Idempotency guard: skip if already sent this period for this type
if ($this->logModel->existsForPeriod($parentId, $year, $month, $type)) {
continue;
}
// Recipient emails: primary guardian (current parent), CC second guardian in same family
$toEmail = $this->getUserEmail($parentId);
$ccEmail = $this->getSecondaryGuardianEmail($parentId);
if (!$toEmail) {
// Log failed attempt due to missing email and continue
$this->logModel->insert([
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => null,
'cc_email' => $ccEmail,
'subject' => 'Monthly Tuition Reminder',
'body' => null,
'status' => 'failed',
'error_message' => 'No primary email on file',
'balance_snapshot' => $totalBalance,
]);
CLI::write("Skipped parent {$parentId} due to missing email", 'yellow');
continue;
}
// Compose email
[$subject, $body] = $this->composeEmail($parentId, $schoolYear, $type, $totalBalance, $now);
$sentOk = $this->emailService->send($toEmail, $subject, $body, 'finance');
if ($sentOk && $ccEmail && strcasecmp($ccEmail, $toEmail) !== 0) {
// Send a separate copy to the secondary guardian
$this->emailService->send($ccEmail, $subject, $body, 'finance');
}
// Notify Head of Finance (in-app)
$headFaUsers = $this->getHeadOfFinanceUsers();
foreach ($headFaUsers as $u) {
NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent',
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $totalBalance),
['in_app']
);
}
$this->logModel->insert([
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $toEmail,
'cc_email' => $ccEmail,
'head_fa_notified' => !empty($headFaUsers) ? 1 : 0,
'subject' => $subject,
'body' => $body,
'status' => $sentOk ? 'sent' : 'failed',
'error_message' => $sentOk ? null : 'Email send failed (see logs)',
'balance_snapshot' => $totalBalance,
'sent_at' => $now->format('Y-m-d H:i:s'),
]);
if ($sentOk) {
$sentCount++;
CLI::write("Reminder sent to {$toEmail}" . ($ccEmail ? ", CC {$ccEmail}" : ''), 'green');
} else {
CLI::write("Failed to send to {$toEmail}", 'red');
}
}
// Summary to head of finance (in-app broadcast)
$headFaUsers = $this->getHeadOfFinanceUsers();
foreach ($headFaUsers as $u) {
NotificationService::toUser((int)$u['id'], 'Monthly Payment Reminders Summary',
sprintf('Total reminders sent this run: %d (School Year: %s).', $sentCount, $schoolYear),
['in_app']
);
}
CLI::write("Done. Total sent: {$sentCount}", 'green');
}
private function isFirstSaturday(\DateTimeInterface $dt): bool
{
// Saturday = 6 (PHP: 0 Sun .. 6 Sat)
$isSaturday = ((int)$dt->format('w')) === 6;
$isFirstWeek = ((int)$dt->format('j')) <= 7;
return $isSaturday && $isFirstWeek;
}
private function getUserEmail(int $userId): ?string
{
$u = $this->userModel->select('email')->find($userId);
$email = $u['email'] ?? null;
return $email && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}
private function getSecondaryGuardianEmail(int $primaryGuardianUserId): ?string
{
// Find family of primary guardian
$row = $this->familyGuardianModel->where('user_id', $primaryGuardianUserId)->first();
if (!$row || empty($row['family_id'])) {
return null;
}
$familyId = (int)$row['family_id'];
$others = $this->familyGuardianModel
->where('family_id', $familyId)
->where('user_id !=', $primaryGuardianUserId)
->where('receive_emails', 1)
->findAll();
foreach ($others as $g) {
$email = $this->getUserEmail((int)$g['user_id']);
if ($email) return $email;
}
return null;
}
private function composeEmail(int $parentId, string $schoolYear, string $type, float $balance, \DateTimeInterface $now): array
{
$parent = $this->userModel->find($parentId) ?: [];
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''));
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$balanceFmt = '$' . number_format($balance, 2);
if ($type === 'no_payment') {
$intro = "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet.";
} else {
$intro = "This is your monthly installment reminder for {$schoolYear}.";
}
// Compute remaining and max installments similar to Manual Payment UI
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
$remMonths = 0;
if ($end) {
$y = (int)$end->format('Y') - (int)$today->format('Y');
$m = (int)$end->format('n') - (int)$today->format('n');
$remMonths = $y * 12 + $m;
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
if ($remMonths < 0) $remMonths = 0;
}
$remainingInst = max(1, $remMonths);
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
$bodyHtml = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<ul>
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
</ul>
<p>
As a friendly reminder, our tuition installments are due at the beginning of each month.
You can review your invoice and payment options by logging in to your parent portal.
</p>
<p>
If you have any questions or need to arrange a different plan, please reply to this email.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
$body = view('emails/_wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => true]);
return [$subject, $body];
}
private function getHeadOfFinanceUsers(): array
{
// Try the explicit role label used in the UI first
$heads = $this->userModel->getUsersByRole('head of department (finance)');
if (!empty($heads)) return $heads;
// fallback to accountant role if needed
$acct = $this->userModel->getUsersByRole('accountant');
return $acct ?: [];
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\PaymentNotificationLogModel;
use App\Models\UserModel;
use App\Models\FamilyGuardianModel;
use App\Services\EmailService;
class SendTestPaymentNotification extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:send-test';
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
public function run(array $params)
{
$email = CLI::getOption('email');
$type = CLI::getOption('type') ?? 'no_payment';
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
CLI::write('Usage: php spark payments:send-test --email=addr@example.com [--type=no_payment|installment]', 'yellow');
return;
}
$config = new ConfigurationModel();
$user = new UserModel();
$invoice= new InvoiceModel();
$logs = new PaymentNotificationLogModel();
$fam = new FamilyGuardianModel();
$mailer = new EmailService();
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$year = (int)$now->format('Y');
$month= (int)$now->format('n');
$schoolYear = (string) ($config->getConfig('school_year') ?? $year);
$userRow = $user->where('email', $email)->first();
$parentId = (int)($userRow['id'] ?? 0);
$invRows = $parentId ? $invoice->getAllInvoicesByUserIds([$parentId], $schoolYear) : [];
$totalBalance = 0.0; $latestInvoiceId = null;
foreach ($invRows as $ir) {
$totalBalance += (float)($ir['balance'] ?? 0);
if ($latestInvoiceId === null) $latestInvoiceId = (int)($ir['id'] ?? 0);
}
// Secondary guardian if available
$ccEmail = null;
if ($parentId) {
$row = $fam->where('user_id', $parentId)->first();
if ($row && !empty($row['family_id'])) {
$others = $fam->where('family_id', (int)$row['family_id'])
->where('user_id !=', $parentId)
->where('receive_emails', 1)->findAll();
foreach ($others as $g) {
$ccEmail = $user->select('email')->find((int)$g['user_id'])['email'] ?? null;
if ($ccEmail && filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) break;
$ccEmail = null;
}
}
}
// Compose body (reuse layout)
$parentName = trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? ''));
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$balanceFmt = '$' . number_format($totalBalance, 2);
$intro = ($type === 'no_payment')
? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."
: "This is your monthly installment reminder for {$schoolYear}.";
$bodyHtml = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<p>
As a friendly reminder, our tuition installments are due at the beginning of each month.
You can review your invoice and payment options by logging in to your parent portal.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
// Compute remaining and max installments similar to Manual Payment UI
$installmentEndRaw = (string)$config->getConfig('installment_date');
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
$remMonths = 0;
if ($end) {
$y = (int)$end->format('Y') - (int)$today->format('Y');
$m = (int)$end->format('n') - (int)$today->format('n');
$remMonths = $y * 12 + $m;
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
if ($remMonths < 0) $remMonths = 0;
}
$remainingInst = max(1, $remMonths);
$maxInst = max(($remMonths ?: 0), ($totalBalance > 0 ? 2 : 0));
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($totalBalance / $remainingInst) : 0, 2);
$bodyHtml .= "<ul>"
. "<li><strong>Remaining installments:</strong> {$remainingInst}</li>"
. "<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>"
. "<li><strong>Maximum installments available:</strong> {$maxInst}</li>"
. "</ul>";
$body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]);
$ok = $mailer->send($email, $subject, $body, 'finance');
if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) {
$mailer->send($ccEmail, $subject, $body, 'finance');
}
$existing = $logs->where('parent_id', $parentId)
->where('period_year', $year)
->where('period_month', $month)
->where('type', $type)
->first();
$payload = [
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $email,
'cc_email' => $ccEmail,
'head_fa_notified' => 0,
'subject' => $subject,
'body' => $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Email send failed (see logs)',
'balance_snapshot' => $totalBalance,
'sent_at' => $now->format('Y-m-d H:i:s'),
];
if ($existing) $logs->update($existing['id'], $payload); else $logs->insert($payload);
CLI::write(($ok ? 'Sent' : 'Failed') . " test reminder to {$email}", $ok ? 'green' : 'red');
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PayPalPaymentModel;
use App\Models\PaymentModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\StudentModel;
use App\Models\EnrollmentModel;
class SyncPaypalPayments extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:sync-paypal';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments';
protected $configModel;
protected $semester;
protected $schoolYear;
protected $paypalModel;
protected $paymentModel;
protected $userModel;
protected $invoiceModel;
protected $studentModel;
protected $enrollmentModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
$this->paypalModel = new PayPalPaymentModel();
$this->paymentModel = new PaymentModel();
$this->userModel = new UserModel();
$this->invoiceModel = new InvoiceModel();
$this->studentModel = new StudentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function run(array $params)
{
$dryRun = CLI::getOption('dry-run');
$reportOnly = CLI::getOption('report-only');
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
$paypalEntries = $this->paypalModel
->where('status', 'COMPLETED')
->where('synced', 0)
->where('sync_attempts <', 3)
->where('transaction_id IS NOT NULL')
->findAll();
$syncedCount = 0;
$failed = [];
foreach ($paypalEntries as $entry) {
$parentId = null;
$invoiceId = 0;
$users = $this->userModel->getUsersBySchoolId($entry['parent_school_id']);
$user = $users[0] ?? null;
// Always increment sync_attempts unless report-only
if (!$reportOnly) {
$this->paypalModel->update($entry['id'], [
'sync_attempts' => $entry['sync_attempts'] + 1
]);
}
if ($user) {
$parentId = $user['id'];
$invoice = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
if (!$reportOnly && !$dryRun) {
if ($invoice) {
$invoiceId = $invoice['id'];
$success = $this->processPayment(
$invoiceId,
$entry['amount'],
'PayPal',
null,
$entry['transaction_id'],
date('Y-m-d', strtotime($entry['created_at'])),
$this->schoolYear,
$this->semester
);
if (!$success) {
$failed[] = $entry['transaction_id'];
continue;
}
} else {
$this->paymentModel->insert([
'parent_id' => $parentId,
'invoice_id' => 0,
'total_amount' => $entry['amount'],
'paid_amount' => $entry['amount'],
'balance' => 0.00,
'number_of_installments' => 1,
'transaction_id' => $entry['transaction_id'],
'payment_method' => 'PayPal',
'payment_date' => date('Y-m-d', strtotime($entry['created_at'])),
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'status' => 'Completed',
'updated_by' => null,
]);
}
// Mark as synced only in LIVE mode
$this->paypalModel->update($entry['id'], ['synced' => 1]);
}
$syncedCount++;
} else {
log_message('error', "[PAYPAL SYNC FAILED] No user found for parent_school_id: {$entry['parent_school_id']}");
$failed[] = $entry['transaction_id'];
}
}
// === Logging ===
log_message('info', "[$mode] PAYPAL SYNC: $syncedCount processed.");
if (!empty($failed)) {
log_message('error', "[$mode] PAYPAL SYNC Failed: " . implode(', ', $failed));
}
// === CLI Output ===
CLI::write("[$mode] $syncedCount PayPal payments processed.", 'green');
if (!empty($failed)) {
CLI::error("[$mode] Failed transactions: " . implode(', ', $failed));
}
// === Email Report: Only if there's any update ===
if ($syncedCount > 0 || !empty($failed)) {
helper('email');
$email = \Config\Services::email();
$email->setTo('support@alrahmaisgl.org');
$email->setFrom('no-parentsreply@alrahmaisgl.org', 'PayPal Sync Report');
$email->setSubject("[$mode] PayPal Sync Report - " . date('Y-m-d H:i'));
$body = "PayPal Sync Mode: $mode\n\n";
$body .= "$syncedCount PayPal payments processed.\n\n";
if (!empty($failed)) {
$body .= count($failed) . " failed transactions:\n";
$body .= implode("\n", $failed);
} else {
$body .= "No failed transactions.\n";
}
$email->setMessage(nl2br($body));
if ($email->send()) {
CLI::write("[$mode] Email report sent successfully.", 'yellow');
} else {
CLI::error("[$mode] Failed to send email report.");
log_message('error', 'Email send error: ' . $email->printDebugger(['headers']));
}
} else {
log_message('info', "[$mode] No PayPal sync updates. Email not sent.");
CLI::write("[$mode] No changes to report. Email not sent.", 'blue');
}
}
private function processPayment($invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return false;
}
$transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time();
$paymentDate = $paymentDate ?? date('Y-m-d');
$newPaid = $invoice['paid_amount'] + $amount;
$newBalance = $invoice['balance'] - $amount;
$invoiceUpdateData = [
'paid_amount' => $newPaid,
'balance' => $newBalance,
'status' => ($newBalance <= 0) ? 'Paid' : $invoice['status'],
];
if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) {
return false;
}
$this->paymentModel->insert([
'parent_id' => $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => 1,
'transaction_id' => $transactionId,
'payment_method' => $paymentMethod,
'payment_date' => $paymentDate,
'status' => ($newBalance <= 0) ? 'Full' : 'Partial',
'check_file' => $checkFile,
'updated_by' => null, // Avoid using session()->get() in CLI
'school_year' => $schoolYear,
'semester' => $semester
]);
$this->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
return true;
}
private function updateEnrollmentStatusIfPaid($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice || $invoice['balance'] > 0) {
return;
}
$students = $this->studentModel->where('parent_id', $invoice['parent_id'])
->where('school_year', $schoolYear)
->findAll();
foreach ($students as $student) {
$this->enrollmentModel->set(['enrollment_status' => 'enrolled'])
->where('student_id', $student['id'])
->update();
}
}
}
-360
View File
@@ -1,360 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\EventModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Controllers\View\InvoiceController;
use CodeIgniter\RESTful\ResourceController;
class EventController extends ResourceController
{
protected $eventChargesModel;
protected $studentModel;
protected $userModel;
protected $configModel;
protected $eventModel;
protected $invoiceController;
protected $schoolYear;
protected $semester;
protected $categories;
protected $enrollmentModel;
public function __construct()
{
$this->eventChargesModel = new EventChargesModel(); //eventChargesModel
$this->studentModel = new StudentModel();
$this->configModel = new ConfigurationModel();
$this->userModel = new UserModel(); // Add this
$this->eventModel = new EventModel();
$this->invoiceController = new InvoiceController();
$this->enrollmentModel = new EnrollmentModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->categories = [
'workshops',
'orientations',
'field trips',
'Ramadan programs',
];
}
public function index()
{
$eventModel = new EventModel();
$today = local_date(utc_now(), 'Y-m-d');
// Fetch all events
$events = $eventModel
->orderBy('created_at', 'DESC')
->findAll();
// Fetch active events (not expired)
$activeEventCount = $eventModel
->where('expiration_date >=', $today)
->countAllResults();
return view('administrator/events/event_list', [
'events' => $events,
'activeEventCount' => $activeEventCount
]);
}
public function create()
{
helper(['form']);
if (strtolower($this->request->getMethod()) === 'post') {
$file = $this->request->getFile('flyer');
$flyerPath = null;
if ($file && $file->isValid() && !$file->hasMoved()) {
// Move to public/uploads/event_flyers
$newName = $file->getRandomName();
$file->move(FCPATH . 'uploads/event_flyers', $newName);
$flyerPath = 'event_flyers/' . $newName; // store relative path
}
$eventId = $this->eventModel->insert([
'event_name' => $this->request->getPost('event_name'),
'event_category' => $this->request->getPost('event_category'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount'),
'flyer' => $flyerPath,
'expiration_date' => $this->request->getPost('expiration_date'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
'created_by' => session()->get('user_id'),
]);
if ($eventId) {
$amount = (float) $this->request->getPost('amount');
$semester = (string) $this->request->getPost('semester');
$schoolYear = (string) $this->request->getPost('school_year');
$userId = (int) (session()->get('user_id') ?? 0);
$enrollments = $this->enrollmentModel
->select('enrollments.student_id, students.parent_id')
->join('students', 'students.id = enrollments.student_id', 'left')
->where('enrollments.school_year', $schoolYear)
->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending'])
->findAll();
$parentIds = [];
foreach ($enrollments as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$parentId = (int) ($row['parent_id'] ?? 0);
if ($studentId <= 0 || $parentId <= 0) {
continue;
}
$exists = $this->eventChargesModel
->where('event_id', $eventId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($exists) {
continue;
}
$this->eventChargesModel->insert([
'event_id' => $eventId,
'parent_id' => $parentId,
'student_id' => $studentId,
'participation' => 'yes',
'charged' => $amount,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $userId ?: null,
]);
$parentIds[] = $parentId;
}
$parentIds = array_unique($parentIds);
foreach ($parentIds as $pid) {
$this->invoiceController->generateInvoice((string) $pid);
}
}
return redirect()->to('/administrator/events')->with('success', 'Event created successfully');
}
return view('administrator/events/create_event', [
'categories' => $this->categories,
]);
}
public function edit($id = null)
{
helper(['form']);
$event = $this->eventModel->find($id);
if (!$event) {
return redirect()->to('/administrator/events')->with('error', 'Event not found');
}
if (strtolower($this->request->getMethod()) === 'post') {
log_message('debug', 'POST detected');
$file = $this->request->getFile('flyer');
$flyerPath = $event['flyer']; // Default: keep old flyer
if ($file && $file->isValid() && !$file->hasMoved()) {
$newName = $file->getRandomName();
$file->move(FCPATH . 'uploads/event_flyers', $newName);
$flyerPath = 'event_flyers/' . $newName; // store relative path
}
$updated = $this->eventModel->update($id, [
'event_name' => $this->request->getPost('event_name'),
'event_category' => $this->request->getPost('event_category'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount'),
'flyer' => $flyerPath,
'expiration_date' => $this->request->getPost('expiration_date'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
]);
if ($updated) {
return redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
} else {
log_message('debug', 'GET detected');
return redirect()->back()->with('error', 'Failed to update event');
}
}
return view('administrator/events/edit_event', [
'event' => $event,
'categories' => $this->categories,
]);
}
public function delete($id = null)
{
$event = $this->eventModel->find($id);
if (!$event) {
return redirect()->to('/administrator/events')->with('error', 'Event not found');
}
// Delete related charges and collect parent IDs
$charges = $this->eventChargesModel->where('event_id', $id)->findAll();
$parentIds = [];
foreach ($charges as $charge) {
$this->eventChargesModel->delete($charge['id']);
$parentIds[] = $charge['parent_id'];
}
// Delete event
$this->eventModel->delete($id);
$parentIds = array_unique($parentIds);
foreach ($parentIds as $parentId) {
$this->invoiceController->generateInvoice($parentId);
}
return redirect()->to('/administrator/events')->with('success', 'Event, charges, and invoices updated.');
}
// Optionally keep your eventShow / eventUpdate for legacy administrator event charges
public function eventShow()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($this->schoolYear);
$charges = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester)
->orderBy('event_charges.created_at', 'DESC')
->findAll();
return view('administrator/events/event_charges', [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
]);
}
public function eventUpdate()
{
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
$semester = $this->request->getPost('semester') ?? $this->semester;
$parentId = $this->request->getPost('parent_id');
$eventId = $this->request->getPost('event_id');
$participations = $this->request->getPost('participation') ?? [];
if (!$parentId || !$eventId || empty($participations)) {
return redirect()->back()->with('error', 'Missing required information.');
}
$userId = session()->get('user_id');
$event = $this->eventModel->getEvent($eventId, $schoolYear);
foreach ($participations as $studentId => $value) {
$existing = $this->eventChargesModel->where([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'school_year' => $schoolYear,
'semester' => $semester
])->first();
if ($value === 'no') {
if ($existing) {
$this->eventChargesModel->delete($existing['id']);
}
continue;
}
// value is 'yes'
if ($existing) {
$this->eventChargesModel->update($existing['id'], [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId
]);
} else {
$this->eventChargesModel->insert([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
'updated_by' => $userId
]);
}
}
$this->invoiceController->generateInvoice($parentId);
return redirect()->back()->with('success', 'Event charges updated successfully.');
}
public function getStudentsWithCharges()
{
$parentId = $this->request->getGet('parent_id');
$semester = $this->request->getGet('semester');
$schoolYear = $this->request->getGet('school_year');
// Get students for parent
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
// Get student_ids that already have charges
$chargedStudentIds = $this->eventChargesModel
->where('parent_id', $parentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id')
->select('student_id')
->findColumn('student_id');
$data = [];
foreach ($students as $student) {
$data[] = [
'id' => $student['id'],
'name' => $student['firstname'] . ' ' . $student['lastname'],
'charged' => in_array($student['id'], $chargedStudentIds ?? []),
];
}
return $this->response->setJSON($data);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Events;
class DeleteUnverifiedUser
{
public $userId;
public function __construct($userId)
{
$this->userId = $userId;
}
public static function deleteAfterTimeout($userId)
{
$model = new self();
// Define the timeout period
$timeoutPeriod = 24 * 60 * 60; // 1 day in seconds
// Retrieve the user's record
$user = $model->find($userId);
if ($user) {
// Get the most recent timestamp to compare with the current time
$lastActivityTime = strtotime($user['updated_at'] ?? $user['created_at']);
// Calculate the time difference
$timeSinceLastActivity = time() - $lastActivityTime;
// Check if the time difference exceeds the timeout period
if ($timeSinceLastActivity > $timeoutPeriod) {
// Delete the user record
return $model->delete($userId);
} else {
// Timeout period has not yet passed
return false; // Or you can return a message indicating no action taken
}
}
// Return false if the user is not found
return false;
}
}
View File
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$authorization = $request->getHeaderLine('Authorization');
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
return $this->unauthorized('Missing or invalid Authorization header');
}
$token = trim(substr($authorization, 7));
if ($token === '') {
return $this->unauthorized('Bearer token is required');
}
$secret = env('JWT_SECRET', 'change-me-in-env');
$payload = jwt_decode($token, $secret);
if (!$payload || empty($payload['sub'])) {
return $this->unauthorized('Invalid or malformed token');
}
if (isset($payload['exp']) && (int) $payload['exp'] < time()) {
return $this->unauthorized('Token has expired');
}
$session = Services::session();
$session->start();
$session->set([
'user_id' => (int) $payload['sub'],
'roles' => (array) ($payload['roles'] ?? []),
]);
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
private function unauthorized(string $message)
{
$response = Services::response();
return $response->setStatusCode(401)->setJSON([
'status' => false,
'message' => $message,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Config\Services;
class ApiDocsAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$authHeader = $request->getHeaderLine('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
$token = trim(substr($authHeader, 7));
try {
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
$decoded = JWT::decode($token, new Key($key, 'HS256'));
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
return $this->denyAccess('Admin privileges required.', ResponseInterface::HTTP_FORBIDDEN);
}
} catch (\Throwable $e) {
return $this->denyAccess('Invalid or expired token.', ResponseInterface::HTTP_UNAUTHORIZED);
}
} else {
$session = session();
if (!$session->get('isLoggedIn') || $session->get('role') !== 'admin') {
if (strpos($request->getHeaderLine('Accept'), 'text/html') !== false) {
return redirect()->to(base_url('login'))->with('error', 'Please log in to access API documentation.');
}
return $this->denyAccess('Unauthorized access to documentation.', ResponseInterface::HTTP_UNAUTHORIZED);
}
}
}
private function denyAccess(string $message, int $status)
{
return Services::response()
->setJSON([
'status' => false,
'message' => $message,
])
->setStatusCode($status);
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
class AuthFilter implements FilterInterface
{
protected $db;
private const LOGIN_TTL = 43200; // 12 hours in seconds
/**
* Optional: map specific paths to role IDs and/or permission names.
* - If a path is not listed here, the menu check ALLOWS by default.
* - You can use either 'roles' (role IDs) or 'permission' (permission name) or both.
* - 'crud' defaults to 'read' if omitted.
*
* Fill these to match your system or leave empty to rely only on route args.
*/
protected array $menuRules = [
// 'nav-builder' => ['permission' => 'nav_builder_access', 'crud' => 'read'],
// 'admin/enrollment/new-students' => ['permission' => 'view_new_students', 'crud' => 'read'],
// 'administrator/contact_information' => ['permission' => 'view_contact_info', 'crud' => 'read'],
// You can also allow specific role IDs directly:
// 'some/admin/page' => ['roles' => [1, 2]], // role_id 1 or 2
];
public function __construct()
{
$this->db = \Config\Database::connect();
}
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
$userRoles = $session->get('roles'); // not used here, but keep if other code expects it
$userId = $session->get('user_id');
$loginTime = (int) $session->get('login_time');
// Must be logged in
if (empty($userRoles) || empty($userId)) {
return redirect()->to('/login');
}
// Enforce 12-hour session lifetime
if ($loginTime <= 0 || (time() - $loginTime) >= self::LOGIN_TTL) {
return $this->handleExpiredSession($request);
}
// Load role IDs for this user
$roleIdRows = $this->db->table('user_roles')
->select('role_id')
->where('user_id', $userId)
->get()->getResultArray();
$roleIds = array_map('intval', array_column($roleIdRows, 'role_id'));
if (empty($roleIds)) {
return $this->deny($request, "You don't have permission to use this feature.");
}
// Route arguments: ['filter' => 'auth:permission_name|alt_permission,update']
// Route arguments patterns supported:
// auth -> default 'read'
// auth:read -> CRUD only
// auth:create -> CRUD only
// auth:update -> CRUD only
// auth:delete -> CRUD only
// auth:permA|permB,read -> permission + CRUD
$requiredPermission = null;
$crudAction = 'read';
if (!empty($arguments)) {
$arg0 = strtolower((string) $arguments[0]);
// If only one argument and it's a CRUD keyword, treat as CRUD-only
if (count($arguments) === 1 && in_array($arg0, ['create', 'read', 'update', 'delete'], true)) {
$crudAction = $arg0;
} else {
// Otherwise, first arg is permission(s); optional second is CRUD
$requiredPermission = $arguments[0]; // e.g., 'edit_student|manage_students'
$crudAction = isset($arguments[1]) ? strtolower((string) $arguments[1]) : 'read';
if (!in_array($crudAction, ['create', 'read', 'update', 'delete'], true)) {
$crudAction = 'read';
}
}
}
// No explicit route permission: fall back to menu rules
if ($this->isAllowedByMenu($request, $roleIds)) {
return; // ✅ allowed
}
return $this->deny($request, "You don't have permission to use this feature.");
}
private function handleExpiredSession(RequestInterface $request)
{
session()->destroy();
if ($request->isAJAX() || $request->getHeaderLine('Accept') === 'application/json') {
return service('response')
->setStatusCode(401)
->setJSON([
'status' => 'expired',
'message' => 'Your session has expired. Please log in again.',
'redirect' => '/login',
]);
}
return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.');
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// No-op
}
/**
* JSON for AJAX, redirect for normal requests.
* Use RequestInterface here (your version used IncomingRequest which was too specific).
*/
public function deny(RequestInterface $request, string $message)
{
if ($request->isAJAX() || $request->getHeaderLine('Accept') === 'application/json') {
return service('response')
->setStatusCode(403)
->setJSON(['status' => 'error', 'message' => $message]);
}
session()->setFlashdata('error', $message);
return redirect()->to('/access_denied');
}
/**
* Checks roles against CRUD flags on a role_permissions row.
*/
private function hasPermission(array $rolePermission, string $crudAction): bool
{
switch ($crudAction) {
case 'create':
return !empty($rolePermission['can_create']);
case 'read':
return !empty($rolePermission['can_read']);
case 'update':
return !empty($rolePermission['can_update']);
case 'delete':
return !empty($rolePermission['can_delete']);
default:
return false;
}
}
/**
* ✅ Missing method added:
* Allow/deny based on $menuRules and the user's role IDs / permissions.
* Defaults to ALLOW if the path has no rule.
*/
private function isAllowedByMenu(RequestInterface $request, array $roleIds): bool
{
// ❌ was: $request->uri->getPath()
$path = ltrim(preg_replace('#/+#', '/', $request->getUri()->getPath()), '/');
$rule = $this->menuRules[$path] ?? null;
if ($rule === null && str_starts_with($path, 'admin/')) {
$rule = $this->menuRules[substr($path, 6)] ?? null;
}
if ($rule === null) {
return true; // or false if you prefer default-deny
}
if (!empty($rule['roles']) && is_array($rule['roles'])) {
foreach ($roleIds as $rid) {
if (in_array((int)$rid, $rule['roles'], true)) return true;
}
// fall through to permission check (if present)
}
if (!empty($rule['permission'])) {
$crud = $rule['crud'] ?? 'read';
$rpRows = $this->db->table('role_permissions rp')
->join('permissions p', 'p.id = rp.permission_id')
->select('rp.*')
->whereIn('rp.role_id', $roleIds)
->where('p.name', $rule['permission'])
->get()->getResultArray();
foreach ($rpRows as $rp) {
if ($this->hasPermission($rp, $crud)) return true;
}
return false;
}
return true;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\I18n\Time;
class CleanupScheduler implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$lastRun = cache('last_cleanup_run');
if ($lastRun === null || Time::now()->difference($lastRun)->getMinutes() >= 2) {
// Trigger the cleanup
$this->runCleanup();
// Update the last run time
cache()->save('last_cleanup_run', Time::now());
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Do nothing after the request
}
private function runCleanup()
{
// Call the cleanup controller method
\CodeIgniter\CLI\CLI::init();
command('cleanup:unverified_users');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class PermissionFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (! session()->get('is_logged_in')) {
return redirect()->to('/user/login');
}
$required = $arguments[0] ?? null;
if ($required) {
$perms = session('permissions') ?? [];
if (! in_array($required, $perms, true)) {
return redirect()->to('/')->with('error', 'Access denied.');
}
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class TimezoneFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// Prime the TimeService detection early in the request lifecycle
try {
service('timeService')->primeFromRequest($request);
} catch (\Throwable $e) {
// ignore; fallback detection will still work lazily
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// No-op
}
}
View File
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Models\UserRoleModel;
use App\Models\RolePermissionModel;
use App\Models\PermissionModel;
function has_permission($userId, $permissionName)
{
$userRoleModel = new UserRoleModel();
$rolePermissionModel = new RolePermissionModel();
$permissionModel = new PermissionModel();
$permission = $permissionModel->where('name', $permissionName)->first();
if (!$permission) {
return false;
}
$permissionId = $permission['id'];
$roles = $userRoleModel->where('user_id', $userId)->findAll();
foreach ($roles as $role) {
$permissions = $rolePermissionModel->where('role_id', $role['role_id'])->where('permission_id', $permissionId)->findAll();
if (!empty($permissions)) {
return true;
}
}
return false;
}
View File
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Models\ConfigurationModel;
// app/Helpers/GlobalConfigHelper.php
if (!function_exists('getSemester')) {
function getSemester() {
$configModel = new \App\Models\ConfigurationModel();
return $configModel->getConfig('semester');
}
}
if (!function_exists('getSchoolYear')) {
function getSchoolYear() {
$configModel = new \App\Models\ConfigurationModel();
return $configModel->getConfig('school_year');
}
}
@@ -0,0 +1,60 @@
<?php
use App\Models\AttendanceCommentTemplateModel;
if (!function_exists('attendance_comment_from_score')) {
function attendance_comment_from_score($score, string $firstName = ''): ?string
{
if ($score === null || $score === '') {
return null;
}
$score = (float) $score;
$template = attendance_comment_template_for_score($score);
if ($template === null) {
return null;
}
$text = trim((string) ($template['template_text'] ?? ''));
if ($text === '') {
return null;
}
$name = trim($firstName) !== '' ? trim($firstName) : 'This student';
$lastChar = $name !== '' ? substr($name, -1) : '';
$namePossessive = ($name === 'This student')
? "This student's"
: ($lastChar === 's' || $lastChar === 'S' ? ($name . "'") : ($name . "'s"));
if (strpos($text, '{name}') !== false) {
return str_replace('{name}', $namePossessive, $text);
}
return $namePossessive . ' ' . $text;
}
}
if (!function_exists('attendance_comment_template_for_score')) {
function attendance_comment_template_for_score(float $score): ?array
{
static $templates = null;
if ($templates === null) {
try {
$model = new AttendanceCommentTemplateModel();
$templates = $model->getActiveTemplates();
} catch (\Throwable $e) {
$templates = [];
}
}
foreach ($templates as $template) {
$min = isset($template['min_score']) ? (float) $template['min_score'] : 0.0;
$max = isset($template['max_score']) ? (float) $template['max_score'] : 100.0;
if ($score >= $min && $score <= $max) {
return $template;
}
}
return null;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
if (!function_exists('base64url_encode')) {
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
if (!function_exists('jwt_encode')) {
/**
* Minimal HS256 JWT encoder.
*
* @param array $payload
* @param string $secret
* @param string $alg Only HS256 supported here
* @return string JWT string
*/
function jwt_encode(array $payload, string $secret, string $alg = 'HS256'): string
{
$header = ['typ' => 'JWT', 'alg' => $alg];
$segments = [];
$segments[] = base64url_encode(json_encode($header, JSON_UNESCAPED_SLASHES));
$segments[] = base64url_encode(json_encode($payload, JSON_UNESCAPED_SLASHES));
$signingInput = implode('.', $segments);
switch ($alg) {
case 'HS256':
$signature = hash_hmac('sha256', $signingInput, $secret, true);
break;
default:
throw new \InvalidArgumentException('Unsupported JWT alg: ' . $alg);
}
$segments[] = base64url_encode($signature);
return implode('.', $segments);
}
}
if (!function_exists('base64url_decode')) {
function base64url_decode(string $data): string
{
$padding = strlen($data) % 4;
if ($padding > 0) {
$data .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($data, '-_', '+/'));
}
}
if (!function_exists('jwt_decode')) {
function jwt_decode(string $token, string $secret, string $alg = 'HS256'): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header64, $payload64, $signature64] = $parts;
$header = json_decode(base64url_decode($header64), true);
$payload = json_decode(base64url_decode($payload64), true);
$signature = base64url_decode($signature64);
if (!is_array($header) || !is_array($payload)) {
return null;
}
$signingInput = $header64 . '.' . $payload64;
switch ($header['alg'] ?? $alg) {
case 'HS256':
$expected = hash_hmac('sha256', $signingInput, $secret, true);
break;
default:
return null;
}
if (!hash_equals($expected, $signature)) {
return null;
}
return $payload;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
if (!function_exists('pbkdf2_hash')) {
function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
{
$salt = random_bytes(16); // 16 bytes = 32 hex characters
$salt_hex = bin2hex($salt);
$derived_key = hash_pbkdf2($algo, $password, $salt, $iterations, $length, true);
$derived_key_hex = bin2hex($derived_key);
return "{$algo}\${$iterations}\${$salt_hex}\${$derived_key_hex}";
}
}
if (!function_exists('pbkdf2_verify')) {
function pbkdf2_verify(string $password, string $stored_hash): bool
{
$parts = explode('$', $stored_hash);
if (count($parts) !== 4) {
return false; // Invalid format
}
[$algo, $iterations, $salt_hex, $derived_key_hex] = $parts;
if (strlen($salt_hex) % 2 !== 0 || strlen($derived_key_hex) % 2 !== 0) {
return false; // Not valid hex
}
$salt = hex2bin($salt_hex);
$derived_key = hex2bin($derived_key_hex);
$length = strlen($derived_key);
$new_derived_key = hash_pbkdf2($algo, $password, $salt, (int)$iterations, $length, true);
return hash_equals($derived_key, $new_derived_key);
}
}
@@ -0,0 +1,27 @@
<?php
use App\Models\ConfigurationModel;
if (!function_exists('selected_teacher_semester')) {
function selected_teacher_semester(): string
{
$session = session();
$selected = trim((string) ($session->get('teacher_scores_selected_semester') ?? ''));
if ($selected !== '') {
return ucfirst(strtolower($selected));
}
$fallback = trim((string) ($session->get('semester') ?? ''));
if ($fallback !== '') {
return ucfirst(strtolower($fallback));
}
$config = new ConfigurationModel();
$configSemester = trim((string) ($config->getConfig('semester') ?? ''));
if ($configSemester !== '') {
return ucfirst(strtolower($configSemester));
}
return 'Fall';
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
use CodeIgniter\I18n\Time;
if (! function_exists('user_timezone')) {
function user_timezone(): string
{
return service('timeService')->userTimezone();
}
}
if (! function_exists('server_timezone')) {
function server_timezone(): string
{
return service('timeService')->serverTimezone();
}
}
if (! function_exists('utc_now')) {
function utc_now(string $format = 'Y-m-d H:i:s'): string
{
return service('timeService')->nowUTC()->format($format);
}
}
if (! function_exists('local_now')) {
function local_now(string $format = 'Y-m-d H:i:s', ?string $tz = null): string
{
return service('timeService')->nowLocal($tz)->format($format);
}
}
if (! function_exists('to_utc')) {
/**
* Convert a time to UTC with optional source TZ and format.
*
* @param string|Time|\DateTimeInterface|null $value
*/
function to_utc($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string
{
return service('timeService')->toUTC($value, $fromTz, $format);
}
}
if (! function_exists('to_local')) {
/**
* Convert a time to user-local with optional source TZ and format.
*
* @param string|Time|\DateTimeInterface|null $value
*/
function to_local($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string
{
return service('timeService')->toLocal($value, $sourceTz, $targetTz, $format);
}
}
if (! function_exists('local_date')) {
/**
* Format to user-local date (Y-m-d by default). Assumes input is UTC unless source TZ provided.
*/
function local_date($value, string $format = 'Y-m-d', ?string $sourceTz = null): string
{
return service('timeService')->formatLocal($value, $format, $sourceTz);
}
}
if (! function_exists('local_datetime')) {
/**
* Format to user-local datetime (Y-m-d H:i by default). Assumes input is UTC unless source TZ provided.
*/
function local_datetime($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string
{
return service('timeService')->formatLocal($value, $format, $sourceTz);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Interfaces;
interface ScoreCalculatorInterface
{
/**
* Calculate score for a student
*
* @param int $studentId
* @param string $semester
* @param string $schoolYear
* @return array Associative array of score data to merge
* @throws \RuntimeException If calculation fails
*/
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array;
}
-39
View File
@@ -1,39 +0,0 @@
<?php
namespace App\Services;
use CodeIgniter\Events\Events;
class NotificationService
{
/**
* Trigger a notification event by type and payload.
*
* @param string $eventType The name of the event (e.g., 'userRegistered')
* @param array $payload The data to pass to the listener
* @return void
*/
public static function trigger(string $eventType, array $payload): void
{
Events::trigger($eventType, $payload);
}
/**
* Helper for sending to a specific user.
*
* @param int $userId
* @param string $title
* @param string $message
* @param array $channels
* @return void
*/
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
{
self::trigger('customNotification', [
'user_id' => $userId,
'title' => $title,
'message' => $message,
'channels' => $channels
]);
}
}
-213
View File
@@ -1,213 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Controllers\View\EmailController;
use App\Models\NotificationModel;
use App\Models\UserNotificationModel;
use App\Models\UserModel;
class NotificationsController extends BaseController
{
protected $notificationModel;
protected $userNotificationModel;
protected $userModel;
public function __construct()
{
$this->notificationModel = new NotificationModel();
$this->userNotificationModel = new UserNotificationModel();
$this->userModel = new UserModel();
}
public function index()
{
$userId = session()->get('user_id');
$notifications = $this->userNotificationModel
->select('notifications.*, user_notifications.is_read')
->join('notifications', 'notifications.id = user_notifications.notification_id')
->where('user_notifications.user_id', $userId)
->orderBy('notifications.created_at', 'DESC')
->findAll();
return view('notifications/index', ['notifications' => $notifications]);
}
public function create()
{
return view('notifications/create');
}
public function send()
{
$title = $this->request->getPost('title');
$message = $this->request->getPost('message');
$group = $this->request->getPost('target_group');
$channels = $this->request->getPost('channels'); // ['in_app', 'email', 'sms']
$notifId = $this->notificationModel->insert([
'title' => $title,
'message' => $message,
'target_group' => $group,
'delivery_channels' => implode(',', $channels),
'created_at' => utc_now()
]);
$users = $this->userModel->where('role', $group)->findAll();
foreach ($users as $user) {
$this->userNotificationModel->insert([
'notification_id' => $notifId,
'user_id' => $user['id'],
'is_read' => false
]);
if (in_array('email', $channels)) {
$this->sendEmail($user['email'], $title, $message);
}
if (in_array('sms', $channels) && !empty($user['phone'])) {
$this->sendSMS($user['phone'], $message);
}
}
return redirect()->back()->with('success', 'Notification sent successfully.');
}
public function markAsRead($id)
{
$userId = session()->get('user_id');
$this->userNotificationModel->where('notification_id', $id)
->where('user_id', $userId)
->set(['is_read' => 1])
->update();
return redirect()->back();
}
public function listActive()
{
helper('url');
$targetGroup = $this->request->getGet('target_group');
return view('notifications/list_active', [
'notificationsEndpoint' => site_url('api/notifications/active'),
'defaultTargetGroup' => $targetGroup,
]);
}
public function listDeleted()
{
helper(['url', 'form']);
return view('notifications/list_deleted', [
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
'restoreEndpoint' => site_url('notifications/restore'),
'csrfTokenName' => csrf_token(),
'csrfTokenValue' => csrf_hash(),
]);
}
public function activeNotificationsData()
{
$targetGroup = $this->request->getGet('target_group');
return $this->response->setJSON($this->buildActiveNotificationsPayload($targetGroup));
}
public function deletedNotificationsData()
{
return $this->response->setJSON($this->buildDeletedNotificationsPayload());
}
public function restore($id)
{
if ($this->notificationModel->restoreNotification($id)) {
return redirect()->to(base_url('notifications/deleted'))->with('success', 'Notification restored successfully.');
}
return redirect()->to(base_url('notifications/deleted'))->with('error', 'Failed to restore notification.');
}
protected function sendEmail($to, $subject, $message)
{
$mailer = new EmailController();
$mailer->sendEmail($to, $subject, $message);
}
protected function sendSMS($phone, $message)
{
log_message('info', "SMS to {$phone}: {$message}");
}
private function buildActiveNotificationsPayload(?string $targetGroup): array
{
$targetGroup = $targetGroup !== null ? trim($targetGroup) : null;
if ($targetGroup === '') {
$targetGroup = null;
}
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
if (!is_array($rows)) {
$rows = [];
}
$now = time();
$notifications = array_map(static function ($row) use ($now) {
if (!is_array($row)) {
return [];
}
$expiresAt = $row['expires_at'] ?? null;
$expiryTs = $expiresAt ? strtotime((string) $expiresAt) : false;
$isExpired = $expiryTs !== false && $expiryTs <= $now;
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $expiresAt,
'created_at' => $row['created_at'] ?? null,
'target_group' => $row['target_group'] ?? null,
'isExpired' => $isExpired,
];
}, $rows);
return [
'notifications' => $notifications,
];
}
private function buildDeletedNotificationsPayload(): array
{
$rows = $this->notificationModel->getDeletedNotifications();
if (!is_array($rows)) {
$rows = [];
}
$notifications = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => $row['id'] ?? null,
'title' => $row['title'] ?? null,
'message' => $row['message'] ?? null,
'priority' => $row['priority'] ?? null,
'scheduled_at' => $row['scheduled_at'] ?? null,
'expires_at' => $row['expires_at'] ?? null,
'deleted_at' => $row['deleted_at'] ?? null,
];
}, $rows);
return [
'notifications' => $notifications,
];
}
}