recreate project

This commit is contained in:
root
2026-02-10 22:11:06 -05:00
commit 663c0cdbda
10149 changed files with 1379710 additions and 0 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');
}
}
+32
View File
@@ -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');
}
}
}
+93
View File
@@ -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();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
/**
* The goal of this file is to allow developers a location
* where they can overwrite core procedural functions and
* replace them with their own. This file is loaded during
* the bootstrap process and is called during the framework's
* execution.
*
* This can be looked at as a `master helper` file that is
* loaded early on, and may also contain additional functions
* that you'd like to use throughout your entire application
*
* @see: https://codeigniter.com/user_guide/extending/common.html
*/
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Api extends BaseConfig
{
public string $baseURL;
public int $timeout;
public array $defaultHeaders;
public function __construct()
{
parent::__construct();
$this->baseURL = rtrim((string) env('API_BASE_URL', ''), '/');
$this->timeout = (int) env('API_TIMEOUT', 10);
$this->defaultHeaders = [
'Accept' => 'application/json',
];
$token = env('API_BEARER_TOKEN');
if ($token) {
$this->defaultHeaders['Authorization'] = 'Bearer ' . $token;
}
}
}
?>
+218
View File
@@ -0,0 +1,218 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class App extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Base Site URL
* --------------------------------------------------------------------------
*
* URL to your CodeIgniter root. Typically, this will be your base URL,
* WITH a trailing slash:
*
* E.g., https://test.alrahmaisgl.org/
*/
public string $baseURL = 'http://localhost:8080/';
/**
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
* If you want to accept multiple Hostnames, set this.
*
* E.g.,
* When your site URL ($baseURL) is 'https://test.alrahmaisgl.org/', and your site
* also accepts 'http://media.example.com/' and 'http://accounts.example.com/':
* ['media.example.com', 'accounts.example.com']
*
* @var list<string>
*/
public array $allowedHostnames = [];
/**
* --------------------------------------------------------------------------
* Index File
* --------------------------------------------------------------------------
*
* Typically, this will be your `index.php` file, unless you've renamed it to
* something else. If you have configured your web server to remove this file
* from your site URIs, set this variable to an empty string.
*/
public string $indexPage = '';
/**
* --------------------------------------------------------------------------
* URI PROTOCOL
* --------------------------------------------------------------------------
*
* This item determines which server global should be used to retrieve the
* URI string. The default setting of 'REQUEST_URI' works for most servers.
* If your links do not seem to work, try one of the other delicious flavors:
*
* 'REQUEST_URI': Uses $_SERVER['REQUEST_URI']
* 'QUERY_STRING': Uses $_SERVER['QUERY_STRING']
* 'PATH_INFO': Uses $_SERVER['PATH_INFO']
*
* WARNING: If you set this to 'PATH_INFO', URIs will always be URL-decoded!
*/
public string $uriProtocol = 'REQUEST_URI';
/*
|--------------------------------------------------------------------------
| Allowed URL Characters
|--------------------------------------------------------------------------
|
| This lets you specify which characters are permitted within your URLs.
| When someone tries to submit a URL with disallowed characters they will
| get a warning message.
|
| As a security measure you are STRONGLY encouraged to restrict URLs to
| as few characters as possible.
|
| By default, only these are allowed: `a-z 0-9~%.:_-`
|
| Set an empty string to allow all characters -- but only if you are insane.
|
| The configured value is actually a regular expression character group
| and it will be used as: '/\A[<permittedURIChars>]+\z/iu'
|
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
*/
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
/**
* --------------------------------------------------------------------------
* Default Locale
* --------------------------------------------------------------------------
*
* The Locale roughly represents the language and location that your visitor
* is viewing the site from. It affects the language strings and other
* strings (like currency markers, numbers, etc), that your program
* should run under for this request.
*/
public string $defaultLocale = 'en';
/**
* --------------------------------------------------------------------------
* Negotiate Locale
* --------------------------------------------------------------------------
*
* If true, the current Request object will automatically determine the
* language to use based on the value of the Accept-Language header.
*
* If false, no automatic detection will be performed.
*/
public bool $negotiateLocale = false;
/**
* --------------------------------------------------------------------------
* Supported Locales
* --------------------------------------------------------------------------
*
* If $negotiateLocale is true, this array lists the locales supported
* by the application in descending order of priority. If no match is
* found, the first locale will be used.
*
* IncomingRequest::setLocale() also uses this list.
*
* @var list<string>
*/
public array $supportedLocales = ['en'];
/**
* --------------------------------------------------------------------------
* Application Timezone
* --------------------------------------------------------------------------
*
* The default timezone that will be used in your application to display
* dates with the date helper, and can be retrieved through app_timezone()
*
* @see https://www.php.net/manual/en/timezones.php for list of timezones
* supported by PHP.
*/
public string $appTimezone = 'UTC';
/**
* --------------------------------------------------------------------------
* Default Character Set
* --------------------------------------------------------------------------
*
* This determines which character set is used by default in various methods
* that require a character set to be provided.
*
* @see http://php.net/htmlspecialchars for a list of supported charsets.
*/
public string $charset = 'UTF-8';
/**
* --------------------------------------------------------------------------
* Force Global Secure Requests
* --------------------------------------------------------------------------
*
* If true, this will force every request made to this application to be
* made via a secure connection (HTTPS). If the incoming request is not
* secure, the user will be redirected to a secure version of the page
* and the HTTP Strict Transport Security (HSTS) header will be set.
*/
public bool $forceGlobalSecureRequests = false;
/**
* --------------------------------------------------------------------------
* Reverse Proxy IPs
* --------------------------------------------------------------------------
*
* If your server is behind a reverse proxy, you must whitelist the proxy
* IP addresses from which CodeIgniter should trust headers such as
* X-Forwarded-For or Client-IP in order to properly identify
* the visitor's IP address.
*
* You need to set a proxy IP address or IP address with subnets and
* the HTTP header for the client IP address.
*
* Here are some examples:
* [
* '10.0.1.200' => 'X-Forwarded-For',
* '192.168.5.0/24' => 'X-Real-IP',
* ]
*
* @var array<string, string>
*/
public array $proxyIPs = [];
/**
* --------------------------------------------------------------------------
* Content Security Policy
* --------------------------------------------------------------------------
*
* Enables the Response's Content Secure Policy to restrict the sources that
* can be used for images, scripts, CSS files, audio, video, etc. If enabled,
* the Response object will populate default values for the policy from the
* `ContentSecurityPolicy.php` file. Controllers can always add to those
* restrictions at run time.
*
* For a better understanding of CSP, see these documents:
*
* @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/
* @see http://www.w3.org/TR/CSP/
*/
public bool $CSPEnabled = false;
public $sessionDriver = 'CodeIgniter\Session\Handlers\DatabaseHandler';
public $sessionSavePath = 'ci_sessions';
// app/Config/App.php
public string $cookiePrefix = '__Host-'; // applies to all cookies set via CI Cookie helper
public string $cookieDomain = ''; // REQUIRED (no Domain for __Host-)
public string $cookiePath = '/';
public bool $cookieSecure = true;
public string $cookieSameSite = 'Lax'; // you can keep Lax; Strict may break some flows
// Sessions (also in App.php)
public string $sessionCookieName = '__Host-ci_session';
public bool $sessionRegenerateDestroy = true;
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace Config;
use CodeIgniter\Config\AutoloadConfig;
/**
* -------------------------------------------------------------------
* AUTOLOADER CONFIGURATION
* -------------------------------------------------------------------
*
* This file defines the namespaces and class maps so the Autoloader
* can find the files as needed.
*
* NOTE: If you use an identical key in $psr4 or $classmap, then
* the values in this file will overwrite the framework's values.
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*
* @immutable
*/
class Autoload extends AutoloadConfig
{
/**
* -------------------------------------------------------------------
* Namespaces
* -------------------------------------------------------------------
* This maps the locations of any namespaces in your application to
* their location on the file system. These are used by the autoloader
* to locate files the first time they have been instantiated.
*
* The '/app' and '/system' directories are already mapped for you.
* you may change the name of the 'App' namespace if you wish,
* but this should be done prior to creating any namespaced classes,
* else you will need to modify all of those classes for this to work.
*
* Prototype:
* $psr4 = [
* 'CodeIgniter' => SYSTEMPATH,
* 'App' => APPPATH
* ];
*
* @var array<string, list<string>|string>
*/
public $psr4 = [
'App' => APPPATH, // To ensure your App namespace is correctly set
APP_NAMESPACE => APPPATH, // For custom app namespace
'Config' => APPPATH . 'Config',
];
/**
* -------------------------------------------------------------------
* Class Map
* -------------------------------------------------------------------
* The class map provides a map of class names and their exact
* location on the drive. Classes loaded in this manner will have
* slightly faster performance because they will not have to be
* searched for within one or more directories as they would if they
* were being autoloaded through a namespace.
*
* Prototype:
* $classmap = [
* 'MyClass' => '/path/to/class/file.php'
* ];
*
* @var array<string, string>
*/
// public $classmap = [];
/**
* -------------------------------------------------------------------
* Files
* -------------------------------------------------------------------
* The files array provides a list of paths to __non-class__ files
* that will be autoloaded. This can be useful for bootstrap operations
* or for loading functions.
*
* Prototype:
* $files = [
* '/path/to/my/file.php',
* ];
*
* @var list<string>
*/
//public $files = [];
/**
* -------------------------------------------------------------------
* Helpers
* -------------------------------------------------------------------
* Prototype:
* $helpers = [
* 'form',
* ];
*
* @var list<string>
*/
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api'];
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| In development, we want to show as many errors as possible to help
| make sure they don't make it to production. And save us hours of
| painful debugging.
|
| If you set 'display_errors' to '1', CI4's detailed error report will show.
*/
//error_reporting(E_ALL);
error_reporting(-1);
ini_set('display_errors', '1');
/*
|--------------------------------------------------------------------------
| DEBUG BACKTRACES
|--------------------------------------------------------------------------
| If true, this constant will tell the error screens to display debug
| backtraces along with the other error information. If you would
| prefer to not see this, set this value to false.
*/
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. This will control whether Kint is loaded, and a few other
| items. It can always be used within your own application too.
*/
defined('CI_DEBUG') || define('CI_DEBUG', true);
+23
View File
@@ -0,0 +1,23 @@
<?php
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| Don't show ANY in production environments. Instead, let the system catch
| it and display a generic error message.
|
| If you set 'display_errors' to '1', CI4's detailed error report will show.
*/
ini_set('display_errors', '0');
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED & ~E_STRICT & ~E_USER_NOTICE & ~E_USER_DEPRECATED);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. It's not widely used currently, and may not survive
| release of the framework.
*/
defined('CI_DEBUG') || define('CI_DEBUG', false);
+38
View File
@@ -0,0 +1,38 @@
<?php
/*
* The environment testing is reserved for PHPUnit testing. It has special
* conditions built into the framework at various places to assist with that.
* You cant use it for your development.
*/
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| In development, we want to show as many errors as possible to help
| make sure they don't make it to production. And save us hours of
| painful debugging.
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
/*
|--------------------------------------------------------------------------
| DEBUG BACKTRACES
|--------------------------------------------------------------------------
| If true, this constant will tell the error screens to display debug
| backtraces along with the other error information. If you would
| prefer to not see this, set this value to false.
*/
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. It's not widely used currently, and may not survive
| release of the framework.
*/
defined('CI_DEBUG') || define('CI_DEBUG', true);
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class CURLRequest extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* CURLRequest Share Options
* --------------------------------------------------------------------------
*
* Whether share options between requests or not.
*
* If true, all the options won't be reset between requests.
* It may cause an error request with unnecessary headers.
*/
public bool $shareOptions = false;
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace Config;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\DummyHandler;
use CodeIgniter\Cache\Handlers\FileHandler;
use CodeIgniter\Cache\Handlers\MemcachedHandler;
use CodeIgniter\Cache\Handlers\PredisHandler;
use CodeIgniter\Cache\Handlers\RedisHandler;
use CodeIgniter\Cache\Handlers\WincacheHandler;
use CodeIgniter\Config\BaseConfig;
class Cache extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Primary Handler
* --------------------------------------------------------------------------
*
* The name of the preferred handler that should be used. If for some reason
* it is not available, the $backupHandler will be used in its place.
*/
public string $handler = 'file';
/**
* --------------------------------------------------------------------------
* Backup Handler
* --------------------------------------------------------------------------
*
* The name of the handler that will be used in case the first one is
* unreachable. Often, 'file' is used here since the filesystem is
* always available, though that's not always practical for the app.
*/
public string $backupHandler = 'dummy';
/**
* --------------------------------------------------------------------------
* Cache Directory Path
* --------------------------------------------------------------------------
*
* The path to where cache files should be stored, if using a file-based
* system.
*
* @deprecated Use the driver-specific variant under $file
*/
public string $storePath = WRITEPATH . 'cache/';
/**
* --------------------------------------------------------------------------
* Cache Include Query String
* --------------------------------------------------------------------------
*
* Whether to take the URL query string into consideration when generating
* output cache files. Valid options are:
*
* false = Disabled
* true = Enabled, take all query parameters into account.
* Please be aware that this may result in numerous cache
* files generated for the same page over and over again.
* ['q'] = Enabled, but only take into account the specified list
* of query parameters.
*
* @var bool|list<string>
*/
public $cacheQueryString = false;
/**
* --------------------------------------------------------------------------
* Key Prefix
* --------------------------------------------------------------------------
*
* This string is added to all cache item names to help avoid collisions
* if you run multiple applications with the same cache engine.
*/
public string $prefix = '';
/**
* --------------------------------------------------------------------------
* Default TTL
* --------------------------------------------------------------------------
*
* The default number of seconds to save items when none is specified.
*
* WARNING: This is not used by framework handlers where 60 seconds is
* hard-coded, but may be useful to projects and modules. This will replace
* the hard-coded value in a future release.
*/
public int $ttl = 60;
/**
* --------------------------------------------------------------------------
* Reserved Characters
* --------------------------------------------------------------------------
*
* A string of reserved characters that will not be allowed in keys or tags.
* Strings that violate this restriction will cause handlers to throw.
* Default: {}()/\@:
*
* NOTE: The default set is required for PSR-6 compliance.
*/
public string $reservedCharacters = '{}()/\@:';
/**
* --------------------------------------------------------------------------
* File settings
* --------------------------------------------------------------------------
* Your file storage preferences can be specified below, if you are using
* the File driver.
*
* @var array<string, int|string|null>
*/
public array $file = [
'storePath' => WRITEPATH . 'cache/',
'mode' => 0640,
];
/**
* -------------------------------------------------------------------------
* Memcached settings
* -------------------------------------------------------------------------
* Your Memcached servers can be specified below, if you are using
* the Memcached drivers.
*
* @see https://codeigniter.com/user_guide/libraries/caching.html#memcached
*
* @var array<string, bool|int|string>
*/
public array $memcached = [
'host' => '127.0.0.1',
'port' => 11211,
'weight' => 1,
'raw' => false,
];
/**
* -------------------------------------------------------------------------
* Redis settings
* -------------------------------------------------------------------------
* Your Redis server can be specified below, if you are using
* the Redis or Predis drivers.
*
* @var array<string, int|string|null>
*/
public array $redis = [
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'database' => 0,
];
/**
* --------------------------------------------------------------------------
* Available Cache Handlers
* --------------------------------------------------------------------------
*
* This is an array of cache engine alias' and class names. Only engines
* that are listed here are allowed to be used.
*
* @var array<string, class-string<CacheInterface>>
*/
public array $validHandlers = [
'dummy' => DummyHandler::class,
'file' => FileHandler::class,
'memcached' => MemcachedHandler::class,
'predis' => PredisHandler::class,
'redis' => RedisHandler::class,
'wincache' => WincacheHandler::class,
];
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseService;
class Commands extends BaseService
{
public static function init()
{
// Register all custom commands
$commands = [
\App\Commands\AttendanceAutoPublishCommand::class,
\App\Commands\CheckMissedPayments::class,
\App\Commands\CleanupExpiredNotifications::class,
\App\Commands\CleanupPasswordResets::class,
\App\Commands\ConfigUpdate::class,
\App\Commands\DeleteInactiveUsers::class,
\App\Commands\SendAbsenteesSummary::class,
\App\Commands\SendLatesSummary::class,
\App\Commands\SendMonthlyPaymentNotifications::class,
\App\Commands\SendTestPaymentNotification::class,
\App\Commands\SyncPaypalPayments::class,
\App\Commands\RecalculateAttendance::class,
];
foreach ($commands as $command) {
if (class_exists($command)) {
\CodeIgniter\CLI\Commands::addCommand(new $command());
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/*
| --------------------------------------------------------------------
| App Namespace
| --------------------------------------------------------------------
|
| This defines the default Namespace that is used throughout
| CodeIgniter to refer to the Application directory. Change
| this constant to change the namespace that all application
| classes should use.
|
| NOTE: changing this will require manually modifying the
| existing namespaces of App\* namespaced-classes.
*/
defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
/*
| --------------------------------------------------------------------------
| Composer Path
| --------------------------------------------------------------------------
|
| The path that Composer's autoload file is expected to live. By default,
| the vendor folder is in the Root directory, but you can customize that here.
*/
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
/*
|--------------------------------------------------------------------------
| Timing Constants
|--------------------------------------------------------------------------
|
| Provide simple ways to work with the myriad of PHP functions that
| require information to be in seconds.
*/
defined('SECOND') || define('SECOND', 1);
defined('MINUTE') || define('MINUTE', 60);
defined('HOUR') || define('HOUR', 3600);
defined('DAY') || define('DAY', 86400);
defined('WEEK') || define('WEEK', 604800);
defined('MONTH') || define('MONTH', 2_592_000);
defined('YEAR') || define('YEAR', 31_536_000);
defined('DECADE') || define('DECADE', 315_360_000);
/*
| --------------------------------------------------------------------------
| Exit Status Codes
| --------------------------------------------------------------------------
|
| Used to indicate the conditions under which the script is exit()ing.
| While there is no universal standard for error codes, there are some
| broad conventions. Three such conventions are mentioned below, for
| those who wish to make use of them. The CodeIgniter defaults were
| chosen for the least overlap with these conventions, while still
| leaving room for others to be defined in future versions and user
| applications.
|
| The three main conventions used for determining exit status codes
| are as follows:
|
| Standard C/C++ Library (stdlibc):
| http://www.gnu.org/software/libc/manual/html_node/Exit-Status.html
| (This link also contains other GNU-specific conventions)
| BSD sysexits.h:
| http://www.gsp.com/cgi-bin/man.cgi?section=3&topic=sysexits
| Bash scripting:
| http://tldp.org/LDP/abs/html/exitcodes.html
|
*/
defined('EXIT_SUCCESS') || define('EXIT_SUCCESS', 0); // no errors
defined('EXIT_ERROR') || define('EXIT_ERROR', 1); // generic error
defined('EXIT_CONFIG') || define('EXIT_CONFIG', 3); // configuration error
defined('EXIT_UNKNOWN_FILE') || define('EXIT_UNKNOWN_FILE', 4); // file not found
defined('EXIT_UNKNOWN_CLASS') || define('EXIT_UNKNOWN_CLASS', 5); // unknown class
defined('EXIT_UNKNOWN_METHOD') || define('EXIT_UNKNOWN_METHOD', 6); // unknown class member
defined('EXIT_USER_INPUT') || define('EXIT_USER_INPUT', 7); // invalid user input
defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8); // database error
defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9); // lowest automatically-assigned error code
defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest automatically-assigned error code
/**
* @deprecated Use \CodeIgniter\Events\Events::PRIORITY_LOW instead.
*/
define('EVENT_PRIORITY_LOW', 200);
/**
* @deprecated Use \CodeIgniter\Events\Events::PRIORITY_NORMAL instead.
*/
define('EVENT_PRIORITY_NORMAL', 100);
/**
* @deprecated Use \CodeIgniter\Events\Events::PRIORITY_HIGH instead.
*/
define('EVENT_PRIORITY_HIGH', 10);
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Stores the default settings for the ContentSecurityPolicy, if you
* choose to use it. The values here will be read in and set as defaults
* for the site. If needed, they can be overridden on a page-by-page basis.
*
* Suggested reference for explanations:
*
* @see https://www.html5rocks.com/en/tutorials/security/content-security-policy/
*/
class ContentSecurityPolicy extends BaseConfig
{
// -------------------------------------------------------------------------
// Broadbrush CSP management
// -------------------------------------------------------------------------
/**
* Default CSP report context
*/
public bool $reportOnly = false;
/**
* Specifies a URL where a browser will send reports
* when a content security policy is violated.
*/
public ?string $reportURI = null;
/**
* Instructs user agents to rewrite URL schemes, changing
* HTTP to HTTPS. This directive is for websites with
* large numbers of old URLs that need to be rewritten.
*/
public bool $upgradeInsecureRequests = false;
// -------------------------------------------------------------------------
// Sources allowed
// NOTE: once you set a policy to 'none', it cannot be further restricted
// -------------------------------------------------------------------------
/**
* Will default to self if not overridden
*
* @var list<string>|string|null
*/
public $defaultSrc;
/**
* Lists allowed scripts' URLs.
*
* @var list<string>|string
*/
public $scriptSrc = 'self';
/**
* Lists allowed stylesheets' URLs.
*
* @var list<string>|string
*/
public $styleSrc = 'self';
/**
* Defines the origins from which images can be loaded.
*
* @var list<string>|string
*/
public $imageSrc = 'self';
/**
* Restricts the URLs that can appear in a page's `<base>` element.
*
* Will default to self if not overridden
*
* @var list<string>|string|null
*/
public $baseURI;
/**
* Lists the URLs for workers and embedded frame contents
*
* @var list<string>|string
*/
public $childSrc = 'self';
/**
* Limits the origins that you can connect to (via XHR,
* WebSockets, and EventSource).
*
* @var list<string>|string
*/
public $connectSrc = 'self';
/**
* Specifies the origins that can serve web fonts.
*
* @var list<string>|string
*/
public $fontSrc;
/**
* Lists valid endpoints for submission from `<form>` tags.
*
* @var list<string>|string
*/
public $formAction = 'self';
/**
* Specifies the sources that can embed the current page.
* This directive applies to `<frame>`, `<iframe>`, `<embed>`,
* and `<applet>` tags. This directive can't be used in
* `<meta>` tags and applies only to non-HTML resources.
*
* @var list<string>|string|null
*/
public $frameAncestors;
/**
* The frame-src directive restricts the URLs which may
* be loaded into nested browsing contexts.
*
* @var list<string>|string|null
*/
public $frameSrc;
/**
* Restricts the origins allowed to deliver video and audio.
*
* @var list<string>|string|null
*/
public $mediaSrc;
/**
* Allows control over Flash and other plugins.
*
* @var list<string>|string
*/
public $objectSrc = 'self';
/**
* @var list<string>|string|null
*/
public $manifestSrc;
/**
* Limits the kinds of plugins a page may invoke.
*
* @var list<string>|string|null
*/
public $pluginTypes;
/**
* List of actions allowed.
*
* @var list<string>|string|null
*/
public $sandbox;
/**
* Nonce tag for style
*/
public string $styleNonceTag = '{csp-style-nonce}';
/**
* Nonce tag for script
*/
public string $scriptNonceTag = '{csp-script-nonce}';
/**
* Replace nonce tag automatically
*/
public bool $autoNonce = true;
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use DateTimeInterface;
class Cookie extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Cookie Prefix
* --------------------------------------------------------------------------
*
* Set a cookie name prefix if you need to avoid collisions.
*/
public string $prefix = '';
/**
* --------------------------------------------------------------------------
* Cookie Expires Timestamp
* --------------------------------------------------------------------------
*
* Default expires timestamp for cookies. Setting this to `0` will mean the
* cookie will not have the `Expires` attribute and will behave as a session
* cookie.
*
* @var DateTimeInterface|int|string
*/
public $expires = 0;
/**
* --------------------------------------------------------------------------
* Cookie Path
* --------------------------------------------------------------------------
*
* Typically will be a forward slash.
*/
public string $path = '/';
/**
* --------------------------------------------------------------------------
* Cookie Domain
* --------------------------------------------------------------------------
*
* Set to `.your-domain.com` for site-wide cookies.
*/
public string $domain = '';
/**
* --------------------------------------------------------------------------
* Cookie Secure
* --------------------------------------------------------------------------
*
* Cookie will only be set if a secure HTTPS connection exists.
*/
public bool $secure = false;
/**
* --------------------------------------------------------------------------
* Cookie HTTPOnly
* --------------------------------------------------------------------------
*
* Cookie will only be accessible via HTTP(S) (no JavaScript).
*/
public bool $httponly = true;
/**
* --------------------------------------------------------------------------
* Cookie SameSite
* --------------------------------------------------------------------------
*
* Configure cookie SameSite setting. Allowed values are:
* - None
* - Lax
* - Strict
* - ''
*
* Alternatively, you can use the constant names:
* - `Cookie::SAMESITE_NONE`
* - `Cookie::SAMESITE_LAX`
* - `Cookie::SAMESITE_STRICT`
*
* Defaults to `Lax` for compatibility with modern browsers. Setting `''`
* (empty string) means default SameSite attribute set by browsers (`Lax`)
* will be set on cookies. If set to `None`, `$secure` must also be set.
*
* @phpstan-var 'None'|'Lax'|'Strict'|''
*/
public string $samesite = 'Lax';
/**
* --------------------------------------------------------------------------
* Cookie Raw
* --------------------------------------------------------------------------
*
* This flag allows setting a "raw" cookie, i.e., its name and value are
* not URL encoded using `rawurlencode()`.
*
* If this is set to `true`, cookie names should be compliant of RFC 2616's
* list of allowed characters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
* @see https://tools.ietf.org/html/rfc2616#section-2.2
*/
public bool $raw = false;
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Cross-Origin Resource Sharing (CORS) Configuration
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
*/
class Cors extends BaseConfig
{
/**
* The default CORS configuration.
*
* @var array{
* allowedOrigins: list<string>,
* allowedOriginsPatterns: list<string>,
* supportsCredentials: bool,
* allowedHeaders: list<string>,
* exposedHeaders: list<string>,
* allowedMethods: list<string>,
* maxAge: int,
* }
*/
public array $default = [
/**
* Origins for the `Access-Control-Allow-Origin` header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
*
* E.g.:
* - ['http://localhost:8080']
* - ['https://www.example.com']
*/
'allowedOrigins' => ['*'], // Allow all origins for mobile apps
/**
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
*
* NOTE: A pattern specified here is part of a regular expression. It will
* be actually `#\A<pattern>\z#`.
*
* E.g.:
* - ['https://\w+\.example\.com']
*/
'allowedOriginsPatterns' => [],
/**
* Weather to send the `Access-Control-Allow-Credentials` header.
*
* The Access-Control-Allow-Credentials response header tells browsers whether
* the server allows cross-origin HTTP requests to include credentials.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
*/
'supportsCredentials' => true, // Enable for mobile apps using cookies/auth
/**
* Set headers to allow.
*
* The Access-Control-Allow-Headers response header is used in response to
* a preflight request which includes the Access-Control-Request-Headers to
* indicate which HTTP headers can be used during the actual request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
*/
'allowedHeaders' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With', 'X-Timezone'],
/**
* Set headers to expose.
*
* The Access-Control-Expose-Headers response header allows a server to
* indicate which response headers should be made available to scripts running
* in the browser, in response to a cross-origin request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
*/
'exposedHeaders' => [],
/**
* Set methods to allow.
*
* The Access-Control-Allow-Methods response header specifies one or more
* methods allowed when accessing a resource in response to a preflight
* request.
*
* E.g.:
* - ['GET', 'POST', 'PUT', 'DELETE']
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
*/
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
/**
* Set how many seconds the results of a preflight request can be cached.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
*/
'maxAge' => 7200,
];
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Config;
use CodeIgniter\Database\Config;
class Database extends Config
{
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
public string $defaultGroup = 'default';
public array $default = [
'DSN' => '',
'hostname' => 'localhost',
'username' => 'u280815660_melabidi',
'password' => '>tNxlRzP/W8',
'database' => 'u280815660_school',
'DBDriver' => 'MySQLi',
'DBPrefix' => '',
'pConnect' => false,
'DBDebug' => (ENVIRONMENT !== 'development'),
'charset' => 'utf8',
'DBCollat' => 'utf8_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => 3306,
];
public array $tests = [
'DSN' => '',
'hostname' => 'localhost',
'username' => 'u280815660_melabidi',
'password' => '>tNxlRzP/W8',
'database' => 'u280815660_school',
'DBDriver' => 'MySQLi',
'DBPrefix' => 'db_',
'pConnect' => false,
'DBDebug' => true,
'charset' => 'utf8',
'DBCollat' => 'utf8_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => 3306,
];
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Config;
/**
* @immutable
*/
class DocTypes
{
/**
* List of valid document types.
*
* @var array<string, string>
*/
public array $list = [
'xhtml11' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
'xhtml1-strict' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
'xhtml1-trans' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
'xhtml1-frame' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">',
'xhtml-basic11' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">',
'html5' => '<!DOCTYPE html>',
'html4-strict' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',
'html4-trans' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">',
'html4-frame' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">',
'mathml1' => '<!DOCTYPE math SYSTEM "http://www.w3.org/Math/DTD/mathml1/mathml.dtd">',
'mathml2' => '<!DOCTYPE math PUBLIC "-//W3C//DTD MathML 2.0//EN" "http://www.w3.org/Math/DTD/mathml2/mathml2.dtd">',
'svg10' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">',
'svg11' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
'svg11-basic' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Basic//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd">',
'svg11-tiny' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Tiny//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd">',
'xhtml-math-svg-xh' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN" "http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd">',
'xhtml-math-svg-sh' => '<!DOCTYPE svg:svg PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN" "http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd">',
'xhtml-rdfa-1' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.0//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-1.dtd">',
'xhtml-rdfa-2' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.1//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-2.dtd">',
];
/**
* Whether to remove the solidus (`/`) character for void HTML elements (e.g. `<input>`)
* for HTML5 compatibility.
*
* Set to:
* `true` - to be HTML5 compatible
* `false` - to be XHTML compatible
*/
public bool $html5 = true;
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Email extends BaseConfig
{
public string $protocol = 'smtp';
public string $SMTPHost = 'smtp.gmail.com';
public string $SMTPUser = 'alrahma.sunday.school@gmail.com';
public string $SMTPPass = 'psnp emdq dykw ypul'; // Consider using ENV()
public int $SMTPPort = 465;
public string $SMTPCrypto = 'ssl'; // ✅ Correct for port 465
public bool $SMTPAuth = true;
public int $SMTPTimeout = 5;
public bool $SMTPKeepAlive = true;
public string $charset = 'UTF-8';
public string $mailType = 'html';
public bool $wordWrap = true;
public string $newline = "\r\n";
public string $CRLF = "\r\n";
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Encryption configuration.
*
* These are the settings used for encryption, if you don't pass a parameter
* array to the encrypter for creation/initialization.
*/
class Encryption extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Encryption Key Starter
* --------------------------------------------------------------------------
*
* If you use the Encryption class you must set an encryption key (seed).
* You need to ensure it is long enough for the cipher and mode you plan to use.
* See the user guide for more info.
*/
public string $key = '';
/**
* --------------------------------------------------------------------------
* Encryption Driver to Use
* --------------------------------------------------------------------------
*
* One of the supported encryption drivers.
*
* Available drivers:
* - OpenSSL
* - Sodium
*/
public string $driver = 'OpenSSL';
/**
* --------------------------------------------------------------------------
* SodiumHandler's Padding Length in Bytes
* --------------------------------------------------------------------------
*
* This is the number of bytes that will be padded to the plaintext message
* before it is encrypted. This value should be greater than zero.
*
* See the user guide for more information on padding.
*/
public int $blockSize = 16;
/**
* --------------------------------------------------------------------------
* Encryption digest
* --------------------------------------------------------------------------
*
* HMAC digest to use, e.g. 'SHA512' or 'SHA256'. Default value is 'SHA512'.
*/
public string $digest = 'SHA512';
/**
* Whether the cipher-text should be raw. If set to false, then it will be base64 encoded.
* This setting is only used by OpenSSLHandler.
*
* Set to false for CI3 Encryption compatibility.
*/
public bool $rawData = true;
/**
* Encryption key info.
* This setting is only used by OpenSSLHandler.
*
* Set to 'encryption' for CI3 Encryption compatibility.
*/
public string $encryptKeyInfo = '';
/**
* Authentication key info.
* This setting is only used by OpenSSLHandler.
*
* Set to 'authentication' for CI3 Encryption compatibility.
*/
public string $authKeyInfo = '';
/**
* Cipher to use.
* This setting is only used by OpenSSLHandler.
*
* Set to 'AES-128-CBC' to decrypt encrypted data that encrypted
* by CI3 Encryption default configuration.
*/
public string $cipher = 'AES-256-CTR';
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace Config;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\FrameworkException;
use CodeIgniter\HotReloader\HotReloader;
use App\Listeners\SchoolEventListener;
use App\Listeners\AttendanceConsequenceListener;
use App\Listeners\WhatsappInviteListener;
use App\Listeners\BelowSixtyEmailListener;
// Create an instance so we can use $this->emailService like your other handlers
$waListener = new WhatsappInviteListener(service('emailService'));
$listener = new SchoolEventListener();
/*
* --------------------------------------------------------------------
* Application Events
* --------------------------------------------------------------------
* Events allow you to tap into the execution of the program without
* modifying or extending core files. This file provides a central
* location to define your events, though they can always be added
* at run-time, also, if needed.
*
* You create code that can execute by subscribing to events with
* the 'on()' method. This accepts any form of callable, including
* Closures, that will be executed when the event is triggered.
*
* Example:
* Events::on('create', [$myInstance, 'myMethod']);
*/
Events::on('pre_system', static function () {
if (ENVIRONMENT !== 'testing') {
if (ini_get('zlib.output_compression')) {
throw FrameworkException::forEnabledZlibOutputCompression();
}
while (ob_get_level() > 0) {
ob_end_flush();
}
ob_start(static fn($buffer) => $buffer);
}
/*
* --------------------------------------------------------------------
* Debug Toolbar Listeners.
* --------------------------------------------------------------------
* If you delete, they will no longer be collected.
*/
if (CI_DEBUG && ! is_cli()) {
Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect');
Services::toolbar()->respond();
// Hot Reload route - for framework use on the hot reloader.
if (ENVIRONMENT === 'development') {
Services::routes()->get('__hot-reload', static function () {
(new HotReloader())->run();
});
}
}
});
// Register the delete unverified user event
Events::on('delete_unverified_user', function ($user) {
(new SchoolEventListener())->handleDeleteUnverifiedUser($user);
});
// User Events
Events::on('userRegistered', [$listener, 'handleStudentRegistered']);
Events::on('userProfileUpdated', [$listener, 'handleUserProfileUpdated']);
Events::on('userDeactivated', [$listener, 'handleUserDeactivated']);
// Students enrollment Events
Events::on('studentRegistered', [$listener, 'handleStudentRegistered']);
Events::on('admissionUnderReview', [$listener, 'handleAdmissionUnderReview']);
Events::on('paymentPending', [$listener, 'handlePaymentPending']);
Events::on('studentEnrolled', [$listener, 'handleStudentEnrolled']);
Events::on('withdrawUnderReview', [$listener, 'handleWithdrawUnderReview']);
Events::on('refundPending', [$listener, 'handleRefundPending']);
Events::on('withdrawn', [$listener, 'handleWithdrawn']);
Events::on('waitlist', [$listener, 'handleWaitlist']);
Events::on('denied', [$listener, 'handleDenied']);
// Score Events
Events::on('scoresPosted', [$listener, 'handleScoresPosted']);
Events::on('finalScoreReleased', [$listener, 'handleFinalScoreReleased']);
// Attendance Events
Events::on('studentMarkedAbsent', [$listener, 'handleStudentMarkedAbsent']);
// Payment Events
Events::on('paymentReceived', [$listener, 'handlePaymentReceived']);
Events::on('paymentMissed', [$listener, 'handlePaymentMissed']);
// Schedule and Messaging
Events::on('classScheduleUpdated', [$listener, 'handleClassScheduleUpdated']);
Events::on('newMessageReceived', [$listener, 'handleNewMessageReceived']);
Events::on('systemAnnouncementPosted', [$listener, 'handleSystemAnnouncementPosted']);
// Account registration
Events::on('user_registered', [$listener, 'handleNewAccountAdded']);
//Custom Notification
Events::on('customNotification', [$listener, 'handleCustomNotification']);
//Extra charge
Events::on('extraCharge', [$listener, 'handleExtraCharge']);
Events::on('attendance.follow_up', [AttendanceConsequenceListener::class, 'followUp']);
Events::on('attendance.final_warning', [AttendanceConsequenceListener::class, 'finalWarning']);
Events::on('attendance.dismissal', [AttendanceConsequenceListener::class, 'dismissal']);
Events::on('below60.email', [BelowSixtyEmailListener::class, 'handle']);
//Whatsapp Event listener
Events::on('whatsapp_invites.send', [$waListener, 'handle']);
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Debug\ExceptionHandler;
use CodeIgniter\Debug\ExceptionHandlerInterface;
use Psr\Log\LogLevel;
use Throwable;
/**
* Setup how the exception handler works.
*/
class Exceptions extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* LOG EXCEPTIONS?
* --------------------------------------------------------------------------
* If true, then exceptions will be logged
* through Services::Log.
*
* Default: true
*/
public bool $log = true;
/**
* --------------------------------------------------------------------------
* DO NOT LOG STATUS CODES
* --------------------------------------------------------------------------
* Any status codes here will NOT be logged if logging is turned on.
* By default, only 404 (Page Not Found) exceptions are ignored.
*
* @var list<int>
*/
public array $ignoreCodes = [404];
/**
* --------------------------------------------------------------------------
* Error Views Path
* --------------------------------------------------------------------------
* This is the path to the directory that contains the 'cli' and 'html'
* directories that hold the views used to generate errors.
*
* Default: APPPATH.'Views/errors'
*/
public string $errorViewPath = APPPATH . 'Views/errors';
/**
* --------------------------------------------------------------------------
* HIDE FROM DEBUG TRACE
* --------------------------------------------------------------------------
* Any data that you would like to hide from the debug trace.
* In order to specify 2 levels, use "/" to separate.
* ex. ['server', 'setup/password', 'secret_token']
*
* @var list<string>
*/
public array $sensitiveDataInTrace = [];
/**
* --------------------------------------------------------------------------
* LOG DEPRECATIONS INSTEAD OF THROWING?
* --------------------------------------------------------------------------
* By default, CodeIgniter converts deprecations into exceptions. Also,
* starting in PHP 8.1 will cause a lot of deprecated usage warnings.
* Use this option to temporarily cease the warnings and instead log those.
* This option also works for user deprecations.
*/
public bool $logDeprecations = true;
/**
* --------------------------------------------------------------------------
* LOG LEVEL THRESHOLD FOR DEPRECATIONS
* --------------------------------------------------------------------------
* If `$logDeprecations` is set to `true`, this sets the log level
* to which the deprecation will be logged. This should be one of the log
* levels recognized by PSR-3.
*
* The related `Config\Logger::$threshold` should be adjusted, if needed,
* to capture logging the deprecations.
*/
public string $deprecationLogLevel = LogLevel::WARNING;
/*
* DEFINE THE HANDLERS USED
* --------------------------------------------------------------------------
* Given the HTTP status code, returns exception handler that
* should be used to deal with this error. By default, it will run CodeIgniter's
* default handler and display the error information in the expected format
* for CLI, HTTP, or AJAX requests, as determined by is_cli() and the expected
* response format.
*
* Custom handlers can be returned if you want to handle one or more specific
* error codes yourself like:
*
* if (in_array($statusCode, [400, 404, 500])) {
* return new \App\Libraries\MyExceptionHandler();
* }
* if ($exception instanceOf PageNotFoundException) {
* return new \App\Libraries\MyExceptionHandler();
* }
*/
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
{
return new ExceptionHandler($this);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Enable/disable backward compatibility breaking features.
*/
class Feature extends BaseConfig
{
/**
* Enable multiple filters for a route or not.
*
* If you enable this:
* - CodeIgniter\CodeIgniter::handleRequest() uses:
* - CodeIgniter\Filters\Filters::enableFilters(), instead of enableFilter()
* - CodeIgniter\CodeIgniter::tryToRouteIt() uses:
* - CodeIgniter\Router\Router::getFilters(), instead of getFilter()
* - CodeIgniter\Router\Router::handle() uses:
* - property $filtersInfo, instead of $filterInfo
* - CodeIgniter\Router\RouteCollection::getFiltersForRoute(), instead of getFilterForRoute()
*/
public bool $multipleFilters = false;
/**
* Use improved new auto routing instead of the default legacy version.
*/
public bool $autoRoutesImproved = false;
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Filters\CSRF;
use CodeIgniter\Filters\DebugToolbar;
use CodeIgniter\Filters\Honeypot;
use CodeIgniter\Filters\InvalidChars;
use CodeIgniter\Filters\SecureHeaders;
class Filters extends BaseConfig
{
/**
* Configures aliases for Filter classes to
* make reading things nicer and simpler.
*
* @var array<string, class-string|list<class-string>> [filter_name => classname]
* or [filter_name => [classname1, classname2, ...]]
*/
public array $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
'permission' => \App\Filters\PermissionFilter::class,
'timezone' => \App\Filters\TimezoneFilter::class,
];
/**
* List of filter aliases that are always
* applied before and after every request.
*
* @var array<string, array<string, array<string, string>>>|array<string, list<string>>
*/
public array $globals = [
'before' => [
'timezone',
'csrf' => ['except' => [
// Webhooks / integrations
'api/paypal-webhook',
'index.php/api/paypal-webhook',
// WhatsApp membership management (legacy allowances retained)
'whatsapp/update-membership',
'index.php/whatsapp/update-membership',
// Attendance management AJAX saves
'attendance/update',
'index.php/attendance/update',
// Late slip preview/print from admin attendance page
'slips/preview',
'index.php/slips/preview',
'slips/print',
'index.php/slips/print',
// ✅ Parent attendance AJAX conflict check (added)
'api/parent/report-attendance/check',
'index.php/api/parent/report-attendance/check',
// API auth endpoints (no CSRF)
'api/login',
'index.php/api/login',
'api/register',
'index.php/api/register',
// API routes (no CSRF, handled by JWT)
'api/*',
'index.php/api/*',
// Badge PDF generation posts (handled by auth; allow multiple submits without reload)
'badge',
'index.php/badge',
]],
],
'after' => [
'toolbar',
'cleanupScheduler' => ['except' => ['cleanup/*']],
],
];
/**
* List of filter aliases that works on a
* particular HTTP method (GET, POST, etc.).
*
* Example:
* 'post' => ['foo', 'bar']
*
* If you use this, you should disable auto-routing because auto-routing
* permits any HTTP method to access a controller. Accessing the controller
* with a method you don't expect could bypass the filter.
*
* @var array<string, list<string>>
*/
public array $methods = [];
/**
* List of filter aliases that should run on any
* before or after URI patterns.
*
* Example:
* 'isLoggedIn' => ['before' => ['account/*', 'profiles/*']]
*
* @var array<string, array<string, list<string>>>
*/
public array $filters = [
];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Config;
use CodeIgniter\Config\ForeignCharacters as BaseForeignCharacters;
/**
* @immutable
*/
class ForeignCharacters extends BaseForeignCharacters
{
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Format\FormatterInterface;
use CodeIgniter\Format\JSONFormatter;
use CodeIgniter\Format\XMLFormatter;
class Format extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Available Response Formats
* --------------------------------------------------------------------------
*
* When you perform content negotiation with the request, these are the
* available formats that your application supports. This is currently
* only used with the API\ResponseTrait. A valid Formatter must exist
* for the specified format.
*
* These formats are only checked when the data passed to the respond()
* method is an array.
*
* @var list<string>
*/
public array $supportedResponseFormats = [
'application/json',
'application/xml', // machine-readable XML
'text/xml', // human-readable XML
];
/**
* --------------------------------------------------------------------------
* Formatters
* --------------------------------------------------------------------------
*
* Lists the class to use to format responses with of a particular type.
* For each mime type, list the class that should be used. Formatters
* can be retrieved through the getFormatter() method.
*
* @var array<string, string>
*/
public array $formatters = [
'application/json' => JSONFormatter::class,
'application/xml' => XMLFormatter::class,
'text/xml' => XMLFormatter::class,
];
/**
* --------------------------------------------------------------------------
* Formatters Options
* --------------------------------------------------------------------------
*
* Additional Options to adjust default formatters behaviour.
* For each mime type, list the additional options that should be used.
*
* @var array<string, int>
*/
public array $formatterOptions = [
'application/json' => JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
'application/xml' => 0,
'text/xml' => 0,
];
/**
* A Factory method to return the appropriate formatter for the given mime type.
*
* @return FormatterInterface
*
* @deprecated This is an alias of `\CodeIgniter\Format\Format::getFormatter`. Use that instead.
*/
public function getFormatter(string $mime)
{
return Services::format()->getFormatter($mime);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Generators extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Generator Commands' Views
* --------------------------------------------------------------------------
*
* This array defines the mapping of generator commands to the view files
* they are using. If you need to customize them for your own, copy these
* view files in your own folder and indicate the location here.
*
* You will notice that the views have special placeholders enclosed in
* curly braces `{...}`. These placeholders are used internally by the
* generator commands in processing replacements, thus you are warned
* not to delete them or modify the names. If you will do so, you may
* end up disrupting the scaffolding process and throw errors.
*
* YOU HAVE BEEN WARNED!
*
* @var array<string, string>
*/
public array $views = [
'make:cell' => 'CodeIgniter\Commands\Generators\Views\cell.tpl.php',
'make:cell_view' => 'CodeIgniter\Commands\Generators\Views\cell_view.tpl.php',
'make:command' => 'CodeIgniter\Commands\Generators\Views\command.tpl.php',
'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php',
'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php',
'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php',
'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php',
'make:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php',
'make:model' => 'CodeIgniter\Commands\Generators\Views\model.tpl.php',
'make:seeder' => 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php',
'make:validation' => 'CodeIgniter\Commands\Generators\Views\validation.tpl.php',
'session:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php',
];
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Honeypot extends BaseConfig
{
/**
* Makes Honeypot visible or not to human
*/
public bool $hidden = true;
/**
* Honeypot Label Content
*/
public string $label = 'Fill This Field';
/**
* Honeypot Field Name
*/
public string $name = 'honeypot';
/**
* Honeypot HTML Template
*/
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';
/**
* Honeypot container
*
* If you enabled CSP, you can remove `style="display:none"`.
*/
public string $container = '<div style="display:none">{template}</div>';
/**
* The id attribute for Honeypot container tag
*
* Used when CSP is enabled.
*/
public string $containerId = 'hpc';
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Images\Handlers\GDHandler;
use CodeIgniter\Images\Handlers\ImageMagickHandler;
class Images extends BaseConfig
{
/**
* Default handler used if no other handler is specified.
*/
public string $defaultHandler = 'gd';
/**
* The path to the image library.
* Required for ImageMagick, GraphicsMagick, or NetPBM.
*/
public string $libraryPath = '/usr/local/bin/convert';
/**
* The available handler classes.
*
* @var array<string, string>
*/
public array $handlers = [
'gd' => GDHandler::class,
'imagick' => ImageMagickHandler::class,
];
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use Kint\Parser\ConstructablePluginInterface;
use Kint\Renderer\AbstractRenderer;
use Kint\Renderer\Rich\TabPluginInterface;
use Kint\Renderer\Rich\ValuePluginInterface;
class Kint extends BaseConfig
{
/**
* Optional custom parser plugins.
*
* @var list<class-string<ConstructablePluginInterface>|ConstructablePluginInterface>|null
*/
public ?array $plugins = null;
/**
* Maximum depth for rendering arrays/objects.
*/
public int $maxDepth = 6;
/**
* Whether to show where the dump was called from.
*/
public bool $displayCalledFrom = true;
/**
* Expand all dump trees by default.
*/
public bool $expanded = false;
/**
* CSS theme for RichRenderer (web output).
*/
public string $richTheme = 'aante-light.css';
/**
* Whether to load the rich folder with Kint assets.
*/
public bool $richFolder = false;
/**
* Enable Kint debugging system-wide.
*
* Set to false for production.
*/
public bool $enabled = false;
/**
* Enable CLI color output (ANSI).
*/
public bool $cliColors = true;
/**
* Force UTF-8 output in CLI.
*/
public bool $cliForceUTF8 = true;
/**
* Let Kint auto-detect the terminal width.
*/
public bool $cliDetectWidth = true;
/**
* Minimum width (in characters) for CLI output.
*/
public int $cliMinWidth = 40;
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Log\Handlers\FileHandler;
class Logger extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Error Logging Threshold
* --------------------------------------------------------------------------
*
* You can enable error logging by setting a threshold over zero. The
* threshold determines what gets logged. Any values below or equal to the
* threshold will be logged.
*
* Threshold options are:
*
* - 0 = Disables logging, Error logging TURNED OFF
* - 1 = Emergency Messages - System is unusable
* - 2 = Alert Messages - Action Must Be Taken Immediately
* - 3 = Critical Messages - Application component unavailable, unexpected exception.
* - 4 = Runtime Errors - Don't need immediate action, but should be monitored.
* - 5 = Warnings - Exceptional occurrences that are not errors.
* - 6 = Notices - Normal but significant events.
* - 7 = Info - Interesting events, like user logging in, etc.
* - 8 = Debug - Detailed debug information.
* - 9 = All Messages
*
* You can also pass an array with threshold levels to show individual error types
*
* array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
*
* For a live site you'll usually enable Critical or higher (3) to be logged otherwise
* your log files will fill up very fast.
*
* @var int|list<int>
*/
//public $threshold = (ENVIRONMENT === 'production') ? 4 : 9;
public $threshold = 4;
/**
* --------------------------------------------------------------------------
* Date Format for Logs
* --------------------------------------------------------------------------
*
* Each item that is logged has an associated date. You can use PHP date
* codes to set your own date formatting
*/
public string $dateFormat = 'Y-m-d H:i:s';
/**
* --------------------------------------------------------------------------
* Log Handlers
* --------------------------------------------------------------------------
*
* The logging system supports multiple actions to be taken when something
* is logged. This is done by allowing for multiple Handlers, special classes
* designed to write the log to their chosen destinations, whether that is
* a file on the getServer, a cloud-based service, or even taking actions such
* as emailing the dev team.
*
* Each handler is defined by the class name used for that handler, and it
* MUST implement the `CodeIgniter\Log\Handlers\HandlerInterface` interface.
*
* The value of each key is an array of configuration items that are sent
* to the constructor of each handler. The only required configuration item
* is the 'handles' element, which must be an array of integer log levels.
* This is most easily handled by using the constants defined in the
* `Psr\Log\LogLevel` class.
*
* Handlers are executed in the order defined in this array, starting with
* the handler on top and continuing down.
*
* @var array<class-string, array<string, int|list<string>|string>>
*/
public array $handlers = [
/*
* --------------------------------------------------------------------
* File Handler
* --------------------------------------------------------------------
*/
FileHandler::class => [
// The log levels that this handler will handle.
'handles' => [
'critical',
'alert',
'emergency',
'debug',
'error',
'info',
'notice',
'warning',
],
/*
* The default filename extension for log files.
* An extension of 'php' allows for protecting the log files via basic
* scripting, when they are to be stored under a publicly accessible directory.
*
* NOTE: Leaving it blank will default to 'log'.
*/
'fileExtension' => '',
/*
* The file system permissions to be applied on newly created log files.
*
* IMPORTANT: This MUST be an integer (no quotes) and you MUST use octal
* integer notation (i.e. 0700, 0644, etc.)
*/
'filePermissions' => 0644,
/*
* Logging Directory Path
*
* By default, logs are written to WRITEPATH . 'logs/'
* Specify a different destination here, if desired.
*/
'path' => '',
],
/*
* The ChromeLoggerHandler requires the use of the Chrome web browser
* and the ChromeLogger extension. Uncomment this block to use it.
*/
// 'CodeIgniter\Log\Handlers\ChromeLoggerHandler' => [
// /*
// * The log levels that this handler will handle.
// */
// 'handles' => ['critical', 'alert', 'emergency', 'debug',
// 'error', 'info', 'notice', 'warning'],
// ],
/*
* The ErrorlogHandler writes the logs to PHP's native `error_log()` function.
* Uncomment this block to use it.
*/
// 'CodeIgniter\Log\Handlers\ErrorlogHandler' => [
// /* The log levels this handler can handle. */
// 'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
//
// /*
// * The message type where the error should go. Can be 0 or 4, or use the
// * class constants: `ErrorlogHandler::TYPE_OS` (0) or `ErrorlogHandler::TYPE_SAPI` (4)
// */
// 'messageType' => 0,
// ],
];
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Migrations extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Enable/Disable Migrations
* --------------------------------------------------------------------------
*
* Migrations are enabled by default.
*
* You should enable migrations whenever you intend to do a schema migration
* and disable it back when you're done.
*/
public bool $enabled = true;
/**
* --------------------------------------------------------------------------
* Migrations Table
* --------------------------------------------------------------------------
*
* This is the name of the table that will store the current migrations state.
* When migrations runs it will store in a database table which migration
* files have already been run.
*/
public string $table = 'migrations';
/**
* --------------------------------------------------------------------------
* Timestamp Format
* --------------------------------------------------------------------------
*
* This is the format that will be used when creating new migrations
* using the CLI command:
* > php spark make:migration
*
* NOTE: if you set an unsupported format, migration runner will not find
* your migration files.
*
* Supported formats:
* - YmdHis_
* - Y-m-d-His_
* - Y_m_d_His_
*/
public string $timestampFormat = 'Y-m-d-His_';
}
+536
View File
@@ -0,0 +1,536 @@
<?php
namespace Config;
/**
* Mimes
*
* This file contains an array of mime types. It is used by the
* Upload class to help identify allowed file types.
*
* When more than one variation for an extension exist (like jpg, jpeg, etc)
* the most common one should be first in the array to aid the guess*
* methods. The same applies when more than one mime-type exists for a
* single extension.
*
* When working with mime types, please make sure you have the ´fileinfo´
* extension enabled to reliably detect the media types.
*
* @immutable
*/
class Mimes
{
/**
* Map of extensions to mime types.
*
* @var array<string, list<string>|string>
*/
public static array $mimes = [
'hqx' => [
'application/mac-binhex40',
'application/mac-binhex',
'application/x-binhex40',
'application/x-mac-binhex40',
],
'cpt' => 'application/mac-compactpro',
'csv' => [
'text/csv',
'text/x-comma-separated-values',
'text/comma-separated-values',
'application/vnd.ms-excel',
'application/x-csv',
'text/x-csv',
'application/csv',
'application/excel',
'application/vnd.msexcel',
'text/plain',
],
'bin' => [
'application/macbinary',
'application/mac-binary',
'application/octet-stream',
'application/x-binary',
'application/x-macbinary',
],
'dms' => 'application/octet-stream',
'lha' => 'application/octet-stream',
'lzh' => 'application/octet-stream',
'exe' => [
'application/octet-stream',
'application/vnd.microsoft.portable-executable',
'application/x-dosexec',
'application/x-msdownload',
],
'class' => 'application/octet-stream',
'psd' => [
'application/x-photoshop',
'image/vnd.adobe.photoshop',
],
'so' => 'application/octet-stream',
'sea' => 'application/octet-stream',
'dll' => 'application/octet-stream',
'oda' => 'application/oda',
'pdf' => [
'application/pdf',
'application/force-download',
'application/x-download',
],
'ai' => [
'application/pdf',
'application/postscript',
],
'eps' => 'application/postscript',
'ps' => 'application/postscript',
'smi' => 'application/smil',
'smil' => 'application/smil',
'mif' => 'application/vnd.mif',
'xls' => [
'application/vnd.ms-excel',
'application/msexcel',
'application/x-msexcel',
'application/x-ms-excel',
'application/x-excel',
'application/x-dos_ms_excel',
'application/xls',
'application/x-xls',
'application/excel',
'application/download',
'application/vnd.ms-office',
'application/msword',
],
'ppt' => [
'application/vnd.ms-powerpoint',
'application/powerpoint',
'application/vnd.ms-office',
'application/msword',
],
'pptx' => [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
'wbxml' => 'application/wbxml',
'wmlc' => 'application/wmlc',
'dcr' => 'application/x-director',
'dir' => 'application/x-director',
'dxr' => 'application/x-director',
'dvi' => 'application/x-dvi',
'gtar' => 'application/x-gtar',
'gz' => 'application/x-gzip',
'gzip' => 'application/x-gzip',
'php' => [
'application/x-php',
'application/x-httpd-php',
'application/php',
'text/php',
'text/x-php',
'application/x-httpd-php-source',
],
'php4' => 'application/x-httpd-php',
'php3' => 'application/x-httpd-php',
'phtml' => 'application/x-httpd-php',
'phps' => 'application/x-httpd-php-source',
'js' => [
'application/x-javascript',
'text/plain',
],
'swf' => 'application/x-shockwave-flash',
'sit' => 'application/x-stuffit',
'tar' => 'application/x-tar',
'tgz' => [
'application/x-tar',
'application/x-gzip-compressed',
],
'z' => 'application/x-compress',
'xhtml' => 'application/xhtml+xml',
'xht' => 'application/xhtml+xml',
'zip' => [
'application/x-zip',
'application/zip',
'application/x-zip-compressed',
'application/s-compressed',
'multipart/x-zip',
],
'rar' => [
'application/vnd.rar',
'application/x-rar',
'application/rar',
'application/x-rar-compressed',
],
'mid' => 'audio/midi',
'midi' => 'audio/midi',
'mpga' => 'audio/mpeg',
'mp2' => 'audio/mpeg',
'mp3' => [
'audio/mpeg',
'audio/mpg',
'audio/mpeg3',
'audio/mp3',
],
'aif' => [
'audio/x-aiff',
'audio/aiff',
],
'aiff' => [
'audio/x-aiff',
'audio/aiff',
],
'aifc' => 'audio/x-aiff',
'ram' => 'audio/x-pn-realaudio',
'rm' => 'audio/x-pn-realaudio',
'rpm' => 'audio/x-pn-realaudio-plugin',
'ra' => 'audio/x-realaudio',
'rv' => 'video/vnd.rn-realvideo',
'wav' => [
'audio/x-wav',
'audio/wave',
'audio/wav',
],
'bmp' => [
'image/bmp',
'image/x-bmp',
'image/x-bitmap',
'image/x-xbitmap',
'image/x-win-bitmap',
'image/x-windows-bmp',
'image/ms-bmp',
'image/x-ms-bmp',
'application/bmp',
'application/x-bmp',
'application/x-win-bitmap',
],
'gif' => 'image/gif',
'jpg' => [
'image/jpeg',
'image/pjpeg',
],
'jpeg' => [
'image/jpeg',
'image/pjpeg',
],
'jpe' => [
'image/jpeg',
'image/pjpeg',
],
'jp2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'j2k' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpf' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpg2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpx' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'jpm' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'mj2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'mjp2' => [
'image/jp2',
'video/mj2',
'image/jpx',
'image/jpm',
],
'png' => [
'image/png',
'image/x-png',
],
'webp' => 'image/webp',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'css' => [
'text/css',
'text/plain',
],
'html' => [
'text/html',
'text/plain',
],
'htm' => [
'text/html',
'text/plain',
],
'shtml' => [
'text/html',
'text/plain',
],
'txt' => 'text/plain',
'text' => 'text/plain',
'log' => [
'text/plain',
'text/x-log',
],
'rtx' => 'text/richtext',
'rtf' => 'text/rtf',
'xml' => [
'application/xml',
'text/xml',
'text/plain',
],
'xsl' => [
'application/xml',
'text/xsl',
'text/xml',
],
'mpeg' => 'video/mpeg',
'mpg' => 'video/mpeg',
'mpe' => 'video/mpeg',
'qt' => 'video/quicktime',
'mov' => 'video/quicktime',
'avi' => [
'video/x-msvideo',
'video/msvideo',
'video/avi',
'application/x-troff-msvideo',
],
'movie' => 'video/x-sgi-movie',
'doc' => [
'application/msword',
'application/vnd.ms-office',
],
'docx' => [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip',
'application/msword',
'application/x-zip',
],
'dot' => [
'application/msword',
'application/vnd.ms-office',
],
'dotx' => [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip',
'application/msword',
],
'xlsx' => [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/vnd.ms-excel',
'application/msword',
'application/x-zip',
],
'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
'word' => [
'application/msword',
'application/octet-stream',
],
'xl' => 'application/excel',
'eml' => 'message/rfc822',
'json' => [
'application/json',
'text/json',
],
'pem' => [
'application/x-x509-user-cert',
'application/x-pem-file',
'application/octet-stream',
],
'p10' => [
'application/x-pkcs10',
'application/pkcs10',
],
'p12' => 'application/x-pkcs12',
'p7a' => 'application/x-pkcs7-signature',
'p7c' => [
'application/pkcs7-mime',
'application/x-pkcs7-mime',
],
'p7m' => [
'application/pkcs7-mime',
'application/x-pkcs7-mime',
],
'p7r' => 'application/x-pkcs7-certreqresp',
'p7s' => 'application/pkcs7-signature',
'crt' => [
'application/x-x509-ca-cert',
'application/x-x509-user-cert',
'application/pkix-cert',
],
'crl' => [
'application/pkix-crl',
'application/pkcs-crl',
],
'der' => 'application/x-x509-ca-cert',
'kdb' => 'application/octet-stream',
'pgp' => 'application/pgp',
'gpg' => 'application/gpg-keys',
'sst' => 'application/octet-stream',
'csr' => 'application/octet-stream',
'rsa' => 'application/x-pkcs7',
'cer' => [
'application/pkix-cert',
'application/x-x509-ca-cert',
],
'3g2' => 'video/3gpp2',
'3gp' => [
'video/3gp',
'video/3gpp',
],
'mp4' => 'video/mp4',
'm4a' => 'audio/x-m4a',
'f4v' => [
'video/mp4',
'video/x-f4v',
],
'flv' => 'video/x-flv',
'webm' => 'video/webm',
'aac' => 'audio/x-acc',
'm4u' => 'application/vnd.mpegurl',
'm3u' => 'text/plain',
'xspf' => 'application/xspf+xml',
'vlc' => 'application/videolan',
'wmv' => [
'video/x-ms-wmv',
'video/x-ms-asf',
],
'au' => 'audio/x-au',
'ac3' => 'audio/ac3',
'flac' => 'audio/x-flac',
'ogg' => [
'audio/ogg',
'video/ogg',
'application/ogg',
],
'kmz' => [
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip',
],
'kml' => [
'application/vnd.google-earth.kml+xml',
'application/xml',
'text/xml',
],
'ics' => 'text/calendar',
'ical' => 'text/calendar',
'zsh' => 'text/x-scriptzsh',
'7zip' => [
'application/x-compressed',
'application/x-zip-compressed',
'application/zip',
'multipart/x-zip',
],
'cdr' => [
'application/cdr',
'application/coreldraw',
'application/x-cdr',
'application/x-coreldraw',
'image/cdr',
'image/x-cdr',
'zz-application/zz-winassoc-cdr',
],
'wma' => [
'audio/x-ms-wma',
'video/x-ms-asf',
],
'jar' => [
'application/java-archive',
'application/x-java-application',
'application/x-jar',
'application/x-compressed',
],
'svg' => [
'image/svg+xml',
'image/svg',
'application/xml',
'text/xml',
],
'vcf' => 'text/x-vcard',
'srt' => [
'text/srt',
'text/plain',
],
'vtt' => [
'text/vtt',
'text/plain',
],
'ico' => [
'image/x-icon',
'image/x-ico',
'image/vnd.microsoft.icon',
],
'stl' => [
'application/sla',
'application/vnd.ms-pki.stl',
'application/x-navistyle',
],
];
/**
* Attempts to determine the best mime type for the given file extension.
*
* @return string|null The mime type found, or none if unable to determine.
*/
public static function guessTypeFromExtension(string $extension)
{
$extension = trim(strtolower($extension), '. ');
if (! array_key_exists($extension, static::$mimes)) {
return null;
}
return is_array(static::$mimes[$extension]) ? static::$mimes[$extension][0] : static::$mimes[$extension];
}
/**
* Attempts to determine the best file extension for a given mime type.
*
* @param string|null $proposedExtension - default extension (in case there is more than one with the same mime type)
*
* @return string|null The extension determined, or null if unable to match.
*/
public static function guessExtensionFromType(string $type, ?string $proposedExtension = null)
{
$type = trim(strtolower($type), '. ');
$proposedExtension = trim(strtolower($proposedExtension ?? ''));
if (
$proposedExtension !== ''
&& array_key_exists($proposedExtension, static::$mimes)
&& in_array($type, (array) static::$mimes[$proposedExtension], true)
) {
// The detected mime type matches with the proposed extension.
return $proposedExtension;
}
// Reverse check the mime type list if no extension was proposed.
// This search is order sensitive!
foreach (static::$mimes as $ext => $types) {
if (in_array($type, (array) $types, true)) {
return $ext;
}
}
return null;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Config;
use CodeIgniter\Modules\Modules as BaseModules;
/**
* Modules Configuration.
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*
* @immutable
*/
class Modules extends BaseModules
{
/**
* --------------------------------------------------------------------------
* Enable Auto-Discovery?
* --------------------------------------------------------------------------
*
* If true, then auto-discovery will happen across all elements listed in
* $aliases below. If false, no auto-discovery will happen at all,
* giving a slight performance boost.
*
* @var bool
*/
public $enabled = true;
/**
* --------------------------------------------------------------------------
* Enable Auto-Discovery Within Composer Packages?
* --------------------------------------------------------------------------
*
* If true, then auto-discovery will happen across all namespaces loaded
* by Composer, as well as the namespaces configured locally.
*
* @var bool
*/
public $discoverInComposer = true;
/**
* The Composer package list for Auto-Discovery
* This setting is optional.
*
* E.g.:
* [
* 'only' => [
* // List up all packages to auto-discover
* 'codeigniter4/shield',
* ],
* ]
* or
* [
* 'exclude' => [
* // List up packages to exclude.
* 'pestphp/pest',
* ],
* ]
*
* @var array{only?: list<string>, exclude?: list<string>}
*/
public $composerPackages = [];
/**
* --------------------------------------------------------------------------
* Auto-Discovery Rules
* --------------------------------------------------------------------------
*
* Aliases list of all discovery classes that will be active and used during
* the current application request.
*
* If it is not listed, only the base application elements will be used.
*
* @var list<string>
*/
public $aliases = [
'events',
'filters',
'registrars',
'routes',
'services',
];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Config;
/**
* Optimization Configuration.
*
* NOTE: This class does not extend BaseConfig for performance reasons.
* So you cannot replace the property values with Environment Variables.
*
* @immutable
*/
class Optimize
{
/**
* --------------------------------------------------------------------------
* Config Caching
* --------------------------------------------------------------------------
*
* @see https://codeigniter.com/user_guide/concepts/factories.html#config-caching
*/
public bool $configCacheEnabled = false;
/**
* --------------------------------------------------------------------------
* Config Caching
* --------------------------------------------------------------------------
*
* @see https://codeigniter.com/user_guide/concepts/autoloader.html#file-locator-caching
*/
public bool $locatorCacheEnabled = false;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Pager extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Templates
* --------------------------------------------------------------------------
*
* Pagination links are rendered out using views to configure their
* appearance. This array contains aliases and the view names to
* use when rendering the links.
*
* Within each view, the Pager object will be available as $pager,
* and the desired group as $pagerGroup;
*
* @var array<string, string>
*/
public array $templates = [
'default_full' => 'CodeIgniter\Pager\Views\default_full',
'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
'default_head' => 'CodeIgniter\Pager\Views\default_head',
'custom_pagination' => 'App\Views\pagination\custom_pagination', // Register custom template
];
/**
* --------------------------------------------------------------------------
* Items Per Page
* --------------------------------------------------------------------------
*
* The default number of results shown in a single page.
*/
public int $perPage = 20;
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace Config;
/**
* Paths
*
* Holds the paths that are used by the system to
* locate the main directories, app, system, etc.
*
* Modifying these allows you to restructure your application,
* share a system folder between multiple applications, and more.
*
* All paths are relative to the project's root folder.
*/
class Paths
{
/**
* ---------------------------------------------------------------
* SYSTEM FOLDER NAME
* ---------------------------------------------------------------
*
* This must contain the name of your "system" folder. Include
* the path if the folder is not in the same directory as this file.
*/
public string $systemDirectory = __DIR__ . '/../../vendor/codeigniter4/framework/system';
/**
* ---------------------------------------------------------------
* APPLICATION FOLDER NAME
* ---------------------------------------------------------------
*
* If you want this front controller to use a different "app"
* folder than the default one you can set its name here. The folder
* can also be renamed or relocated anywhere on your server. If
* you do, use a full server path.
*
* @see http://codeigniter.com/user_guide/general/managing_apps.html
*/
public string $appDirectory = __DIR__ . '/..';
/**
* ---------------------------------------------------------------
* WRITABLE DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of your "writable" directory.
* The writable directory allows you to group all directories that
* need write permission to a single place that can be tucked away
* for maximum security, keeping it out of the app and/or
* system directories.
*/
public string $writableDirectory = __DIR__ . '/../../writable';
/**
* ---------------------------------------------------------------
* TESTS DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of your "tests" directory.
*/
public string $testsDirectory = __DIR__ . '/../../tests';
/**
* ---------------------------------------------------------------
* VIEW DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of the directory that
* contains the view files used by your application. By
* default this is in `app/Views`. This value
* is used when no value is provided to `Services::renderer()`.
*/
public string $viewDirectory = __DIR__ . '/../Views';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class PaypalConfig extends BaseConfig
{
// PayPal API Credentials
public $paypalClientId = 'YOUR_PAYPAL_CLIENT_ID';
public $paypalSecret = 'YOUR_PAYPAL_SECRET';
public $paypalMode = 'sandbox'; // Change to 'live' when moving to production
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Config;
use CodeIgniter\Config\Publisher as BasePublisher;
/**
* Publisher Configuration
*
* Defines basic security restrictions for the Publisher class
* to prevent abuse by injecting malicious files into a project.
*/
class Publisher extends BasePublisher
{
/**
* A list of allowed destinations with a (pseudo-)regex
* of allowed files for each destination.
* Attempts to publish to directories not in this list will
* result in a PublisherException. Files that do no fit the
* pattern will cause copy/merge to fail.
*
* @var array<string, string>
*/
public $restrictions = [
ROOTPATH => '*',
FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i',
];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Config;
use CodeIgniter\Config\BaseConfig;
class Roles extends BaseConfig
{
/** Default roles (lowercase) used if a roles table is not available */
public array $roles = [
'administrator', 'principal', 'vice_principal', 'admin',
'head of department (communication)',
'head of department (information technology)',
'head of department (education)'
];
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Config;
use CodeIgniter\Config\Routing as BaseRouting;
/**
* Routing configuration
*/
class Routing extends BaseRouting
{
/**
* An array of files that contain route definitions.
* Route files are read in order, with the first match
* found taking precedence.
*
* Default: APPPATH . 'Config/Routes.php'
*
* @var list<string>
*/
public array $routeFiles = [
APPPATH . 'Config/Routes.php',
];
/**
* The default namespace to use for Controllers when no other
* namespace has been specified.
*
* Default: 'App\Controllers'
*/
public string $defaultNamespace = 'App\Controllers';
/**
* The default controller to use when no other controller has been
* specified.
*
* Default: 'Home'
*/
public string $defaultController = 'Home';
/**
* The default method to call on the controller when no other
* method has been set in the route.
*
* Default: 'index'
*/
public string $defaultMethod = 'index';
/**
* Whether to translate dashes in URIs to underscores.
* Primarily useful when using the auto-routing.
*
* Default: false
*/
public bool $translateURIDashes = false;
/**
* Sets the class/method that should be called if routing doesn't
* find a match. It can be the controller/method name like: Users::index
*
* This setting is passed to the Router class and handled there.
*
* If you want to use a closure, you will have to set it in the
* routes file by calling:
*
* $routes->set404Override(function() {
* // Do something here
* });
*
* Example:
* public $override404 = 'App\Errors::show404';
*/
public ?string $override404 = null;
/**
* If TRUE, the system will attempt to match the URI against
* Controllers by matching each segment against folders/files
* in APPPATH/Controllers, when a match wasn't found against
* defined routes.
*
* If FALSE, will stop searching and do NO automatic routing.
*/
public bool $autoRoute = false;
/**
* If TRUE, will enable the use of the 'prioritize' option
* when defining routes.
*
* Default: false
*/
public bool $prioritize = false;
/**
* Map of URI segments and namespaces. For Auto Routing (Improved).
*
* The key is the first URI segment. The value is the controller namespace.
* E.g.,
* [
* 'blog' => 'Acme\Blog\Controllers',
* ]
*
* @var array<string, string>
*/
public array $moduleRoutes = [];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class School extends BaseConfig
{
/**
* Attendance-related settings.
* Access via: config('School')->attendance['timezone']
*/
public array $attendance = [
// School-local timezone used for auto-publish calculations
'timezone' => 'America/New_York',
// Strategy label (for reference; your code uses the helper library)
'autopublish_strategy' => 'second_sunday_after',
// Optional toggles you might use later:
// 'require_reason_on_reopen' => true,
];
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Security extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* CSRF Protection Method
* --------------------------------------------------------------------------
*
* Protection Method for Cross Site Request Forgery protection.
*
* @var string 'cookie' or 'session'
*/
public string $csrfProtection = 'cookie';
/**
* --------------------------------------------------------------------------
* CSRF Token Randomization
* --------------------------------------------------------------------------
*
* Randomize the CSRF Token for added security.
*/
public bool $tokenRandomize = false;
/**
* --------------------------------------------------------------------------
* CSRF Token Name
* --------------------------------------------------------------------------
*
* Token name for Cross Site Request Forgery protection.
*/
public string $tokenName = 'csrf_test_name';
/**
* --------------------------------------------------------------------------
* CSRF Header Name
* --------------------------------------------------------------------------
*
* Header name for Cross Site Request Forgery protection.
*/
public string $headerName = 'X-CSRF-TOKEN';
/**
* --------------------------------------------------------------------------
* CSRF Cookie Name
* --------------------------------------------------------------------------
*
* Cookie name for Cross Site Request Forgery protection.
*/
public string $cookieName = 'csrf_cookie_name';
/**
* --------------------------------------------------------------------------
* CSRF Expires
* --------------------------------------------------------------------------
*
* Expiration time for Cross Site Request Forgery protection cookie.
*
* Defaults to two hours (in seconds).
*/
public int $expires = 7200;
/**
* --------------------------------------------------------------------------
* CSRF Regenerate
* --------------------------------------------------------------------------
*
* Regenerate CSRF Token on every submission.
*/
public bool $regenerate = true;
/**
* --------------------------------------------------------------------------
* CSRF Redirect
* --------------------------------------------------------------------------
*
* Redirect to previous page with error on failure.
*/
public bool $redirect = true;
/**
* --------------------------------------------------------------------------
* CSRF SameSite
* --------------------------------------------------------------------------
*
* Setting for CSRF SameSite cookie token.
*
* Allowed values are: None - Lax - Strict - ''.
*
* Defaults to `Lax` as recommended in this link:
*
* @see https://portswigger.net/web-security/csrf/samesite-cookies
*
* @deprecated `Config\Cookie` $samesite property is used.
*/
public string $samesite = 'Lax';
// app/Config/Security.php
public string $csrfCookieName = '__Host-csrf';
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseService;
use App\Services\SemesterScoreService;
use App\Models\SemesterScoreModel;
use App\Models\ConfigurationModel;
use App\Services\Calculators\HomeworkCalculator;
use App\Services\Calculators\QuizCalculator;
use App\Services\Calculators\ProjectCalculator;
use App\Services\Calculators\AttendanceCalculator;
use App\Models\HomeworkModel;
use App\Models\QuizModel;
use App\Models\ProjectModel;
use App\Models\AttendanceRecordModel;
use App\Models\CalendarModel;
use PHPMailer\PHPMailer\PHPMailer;
/**
* Services Configuration file.
*
* Services are simply other classes/libraries that the system uses
* to do its job. This is used by CodeIgniter to allow the core of the
* framework to be swapped out easily without affecting the usage within
* the rest of your application.
*
* This file holds any application-specific services, or service overrides
* that you might need. An example has been included with the general
* method format you should use for your service methods. For more examples,
* see the core Services file at system/Config/Services.php.
*/
class Services extends BaseService
{
/**
* Override CI Email service to enforce a global Reply-To.
*/
public static function email(array $config = null, bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('email', $config);
}
$config = $config ?? config('Email');
$email = new \CodeIgniter\Email\Email($config);
try {
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
$rtNameRaw = env('MAIL_DEFAULT_REPLY_TO_NAME');
$rtName = 'Al Rahma Sunday School';
if (is_string($rtNameRaw)) {
$rtNameRaw = trim($rtNameRaw);
if ($rtNameRaw !== '' && !preg_match('/^no[- ]?repl(?:y|ay)$/i', $rtNameRaw)) {
$rtName = $rtNameRaw;
}
}
if ($rtEmail) {
$email->setReplyTo($rtEmail, $rtName);
}
} catch (\Throwable $e) {
// Ignore; ensure service is still usable even if headers not yet setable
}
return $email;
}
public static function PHPMailer(bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('PHPMailer');
}
$mail = new PHPMailer(true);
// Set up your PHPMailer configuration
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com'; // Your SMTP host
$mail->SMTPAuth = true;
$mail->Username = 'alrahma.sunday.school@gmail.com'; // Your email
$mail->Password = 'psnp emdq dykw ypul'; // Your email password
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = 465;
$mail->Timeout = 10; // ⏱ 10-second timeout max
$mail->SMTPKeepAlive = false; // Prevent hanging connections
$mail->SMTPDebug = 0; // Set to 2 temporarily for debugging
$mail->SMTPDebug = 2; // You can set it to 1, 2, or 3 for increasing verbosity
$mail->Debugoutput = 'html'; // You can output it to 'html' or 'error_log'
return $mail;
}
/**
* Build (or fetch a shared) SemesterScoreService instance.
*
* Usage:
* $svc = service('semesterScoreService'); // shared
* $svc = \Config\Services::semesterScoreService(); // shared
* $svc = \Config\Services::semesterScoreService(false); // NEW instance (not shared)
*/
public static function semesterScoreService(bool $getShared = true): SemesterScoreService
{
if ($getShared) {
return static::getSharedInstance('semesterScoreService');
}
// Core models
$scoreModel = new SemesterScoreModel();
$configModel = new ConfigurationModel();
// Calculators (inject their models)
$homeworkCalc = new HomeworkCalculator(new HomeworkModel());
$quizCalc = new QuizCalculator(new QuizModel());
$projectCalc = new ProjectCalculator(new ProjectModel());
$attendanceCalc = new AttendanceCalculator(
new AttendanceRecordModel(),
new ConfigurationModel(),
new CalendarModel()
);
// If you later add Midterm/Final/Participation calculators, plug them in here.
return new SemesterScoreService(
$scoreModel,
$configModel,
$homeworkCalc,
$quizCalc,
$projectCalc,
$attendanceCalc
);
}
public static function roleService($getShared = true)
{
if ($getShared) {
return static::getSharedInstance('roleService');
}
return new \App\Services\RoleService();
}
public static function emailService($getShared = true)
{
if ($getShared) {
return static::getSharedInstance('emailService');
}
// Replace with your real EmailService class + constructor args
return new \App\Services\EmailService();
}
/**
* TimeService shared builder (timezone detection + conversions).
*/
public static function timeService(bool $getShared = true): \App\Services\TimeService
{
if ($getShared) {
return static::getSharedInstance('timeService');
}
return new \App\Services\TimeService();
}
/**
* Shared API client for outbound HTTP to external services.
*/
public static function apiClient(bool $getShared = true): \App\Services\ApiClient
{
if ($getShared) {
return static::getSharedInstance('apiClient');
}
$apiConfig = config('Api');
$options = [
'baseURI' => $apiConfig->baseURL ?: null,
'timeout' => $apiConfig->timeout,
'headers' => $apiConfig->defaultHeaders,
];
// Remove null/empty values that may cause issues
$options = array_filter($options, static function ($v) {
return $v !== null && $v !== '';
});
$http = static::curlrequest($options);
return new \App\Services\ApiClient($http, $apiConfig);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\FileHandler;
class Session extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Session Driver
* --------------------------------------------------------------------------
*
* The session storage driver to use:
* - `CodeIgniter\Session\Handlers\FileHandler`
* - `CodeIgniter\Session\Handlers\DatabaseHandler`
* - `CodeIgniter\Session\Handlers\MemcachedHandler`
* - `CodeIgniter\Session\Handlers\RedisHandler`
*
* @var class-string<BaseHandler>
*/
public string $driver = FileHandler::class;
/**
* --------------------------------------------------------------------------
* Session Cookie Name
* --------------------------------------------------------------------------
*
* The session cookie name, must contain only [0-9a-z_-] characters
*/
public string $cookieName = 'ci_session';
public string $cookieDomain = ''; // Leave blank for localhost
public bool $cookieSecure = false; // Set to false if not using HTTPS
/**
* --------------------------------------------------------------------------
* Session Expiration
* --------------------------------------------------------------------------
*
* The number of SECONDS you want the session to last.
* Setting to 0 (zero) means expire when the browser is closed.
*/
public int $expiration = 86400;
/**
* --------------------------------------------------------------------------
* Session Save Path
* --------------------------------------------------------------------------
*
* The location to save sessions to and is driver dependent.
*
* For the 'files' driver, it's a path to a writable directory.
* WARNING: Only absolute paths are supported!
*
* For the 'database' driver, it's a table name.
* Please read up the manual for the format with other session drivers.
*
* IMPORTANT: You are REQUIRED to set a valid save path!
*/
public string $savePath = WRITEPATH . 'session';
/**
* --------------------------------------------------------------------------
* Session Match IP
* --------------------------------------------------------------------------
*
* Whether to match the user's IP address when reading the session data.
*
* WARNING: If you're using the database driver, don't forget to update
* your session table's PRIMARY KEY when changing this setting.
*/
public bool $matchIP = false;
/**
* --------------------------------------------------------------------------
* Session Time to Update
* --------------------------------------------------------------------------
*
* How many seconds between CI regenerating the session ID.
*/
public int $timeToUpdate = 300;
/**
* --------------------------------------------------------------------------
* Session Regenerate Destroy
* --------------------------------------------------------------------------
*
* Whether to destroy session data associated with the old session ID
* when auto-regenerating the session ID. When set to FALSE, the data
* will be later deleted by the garbage collector.
*/
public bool $regenerateDestroy = false;
/**
* --------------------------------------------------------------------------
* Session Database Group
* --------------------------------------------------------------------------
*
* DB Group for the database session.
*/
public ?string $DBGroup = null;
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Config;
class SessionTimeout
{
// Session timeout in seconds (12 hours)
public const TIMEOUT_DURATION = 43200;
// Warning threshold in seconds (5 minutes before timeout)
public const WARNING_THRESHOLD = 42900;
// Server-side check interval (in seconds)
public const CHECK_INTERVAL = 300; // 5 minutes
// Client-side check interval (in milliseconds)
public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Style extends BaseConfig
{
// Accent color palettes (used as the app primary color in management views)
public $stylePalettes = [
'blue' => [
'primary' => '#4da3ff',
'primary_hover' => '#1d7eff',
'thead_bg' => '#f0f7ff',
'thead_border' => 'rgba(29, 126, 255, 0.25)',
],
'green' => [
'primary' => '#22c55e',
'primary_hover' => '#16a34a',
'thead_bg' => '#ecfdf5',
'thead_border' => 'rgba(22, 163, 74, 0.25)',
],
'purple' => [
'primary' => '#8b5cf6',
'primary_hover' => '#7c3aed',
'thead_bg' => '#f5f3ff',
'thead_border' => 'rgba(124, 58, 237, 0.25)',
],
'orange' => [
'primary' => '#f59e0b',
'primary_hover' => '#d97706',
'thead_bg' => '#fff7ed',
'thead_border' => 'rgba(217, 119, 6, 0.25)',
],
// Additional accents
'red' => [
'primary' => '#ef4444',
'primary_hover' => '#dc2626',
'thead_bg' => '#fef2f2',
'thead_border' => 'rgba(220, 38, 38, 0.25)',
],
'teal' => [
'primary' => '#14b8a6',
'primary_hover' => '#0d9488',
'thead_bg' => '#f0fdfa',
'thead_border' => 'rgba(13, 148, 136, 0.25)',
],
'cyan' => [
'primary' => '#06b6d4',
'primary_hover' => '#0891b2',
'thead_bg' => '#ecfeff',
'thead_border' => 'rgba(8, 145, 178, 0.25)',
],
'rose' => [
'primary' => '#f43f5e',
'primary_hover' => '#e11d48',
'thead_bg' => '#fff1f2',
'thead_border' => 'rgba(225, 29, 72, 0.25)',
],
'indigo' => [
'primary' => '#6366f1',
'primary_hover' => '#4f46e5',
'thead_bg' => '#eef2ff',
'thead_border' => 'rgba(79, 70, 229, 0.25)',
],
'slate' => [
'primary' => '#64748b',
'primary_hover' => '#475569',
'thead_bg' => '#f1f5f9',
'thead_border' => 'rgba(71, 85, 105, 0.25)',
],
'amber' => [
'primary' => '#f59e0b',
'primary_hover' => '#b45309',
'thead_bg' => '#fffbeb',
'thead_border' => 'rgba(180, 83, 9, 0.25)',
],
'lime' => [
'primary' => '#84cc16',
'primary_hover' => '#4d7c0f',
'thead_bg' => '#f7fee7',
'thead_border' => 'rgba(77, 124, 15, 0.25)',
],
'fuchsia' => [
'primary' => '#d946ef',
'primary_hover' => '#a21caf',
'thead_bg' => '#fdf4ff',
'thead_border' => 'rgba(162, 28, 175, 0.25)',
],
'violet' => [
'primary' => '#7c3aed',
'primary_hover' => '#5b21b6',
'thead_bg' => '#f5f3ff',
'thead_border' => 'rgba(91, 33, 182, 0.25)',
],
'ocean' => [
'primary' => '#0284c7',
'primary_hover' => '#0c4a6e',
'thead_bg' => '#e0f2fe',
'thead_border' => 'rgba(12, 74, 110, 0.25)',
],
'copper' => [
'primary' => '#b45309',
'primary_hover' => '#7c2d12',
'thead_bg' => '#fff7ed',
'thead_border' => 'rgba(124, 45, 18, 0.25)',
],
'gold' => [
'primary' => '#ca8a04',
'primary_hover' => '#92400e',
'thead_bg' => '#fefce8',
'thead_border' => 'rgba(146, 64, 14, 0.25)',
],
'mint' => [
'primary' => '#10b981',
'primary_hover' => '#047857',
'thead_bg' => '#ecfdf3',
'thead_border' => 'rgba(4, 120, 87, 0.25)',
],
'sky' => [
'primary' => '#38bdf8',
'primary_hover' => '#0284c7',
'thead_bg' => '#f0f9ff',
'thead_border' => 'rgba(2, 132, 199, 0.25)',
],
'berry' => [
'primary' => '#be185d',
'primary_hover' => '#9d174d',
'thead_bg' => '#fff1f2',
'thead_border' => 'rgba(157, 23, 77, 0.25)',
],
'peacock' => [
'primary' => '#0f766e',
'primary_hover' => '#115e59',
'thead_bg' => '#f0fdfa',
'thead_border' => 'rgba(17, 94, 89, 0.25)',
],
'espresso' => [
'primary' => '#6f4e37',
'primary_hover' => '#5b3a29',
'thead_bg' => '#f5f0eb',
'thead_border' => 'rgba(91, 58, 41, 0.25)',
],
];
// Menu color palettes (top management navbar + header area)
// mode: impacts whether we render navbar-light or navbar-dark for toggler/contrast
public $menuPalettes = [
'white' => [ 'bg' => '#ffffff', 'text' => '#334155', 'mode' => 'light' ],
'light' => [ 'bg' => '#f8fafc', 'text' => '#334155', 'mode' => 'light' ],
'brand' => [ 'bg' => '#1d7eff', 'text' => '#ffffff', 'mode' => 'dark' ],
'dark' => [ 'bg' => '#0f172a', 'text' => '#e2e8f0', 'mode' => 'dark' ],
// Additional menu color sets
'sky' => [ 'bg' => '#0ea5e9', 'text' => '#ffffff', 'mode' => 'dark' ],
'emerald' => [ 'bg' => '#059669', 'text' => '#ffffff', 'mode' => 'dark' ],
'teal' => [ 'bg' => '#0f766e', 'text' => '#e2e8f0', 'mode' => 'dark' ],
'navy' => [ 'bg' => '#0b2a4a', 'text' => '#e2e8f0', 'mode' => 'dark' ],
'slate' => [ 'bg' => '#f1f5f9', 'text' => '#334155', 'mode' => 'light' ],
'graphite' => [ 'bg' => '#111827', 'text' => '#f3f4f6', 'mode' => 'dark' ],
'sand' => [ 'bg' => '#fafaf9', 'text' => '#374151', 'mode' => 'light' ],
'rose' => [ 'bg' => '#be123c', 'text' => '#ffffff', 'mode' => 'dark' ],
'amber' => [ 'bg' => '#b45309', 'text' => '#ffffff', 'mode' => 'dark' ],
'moss' => [ 'bg' => '#4d7c0f', 'text' => '#fefce8', 'mode' => 'dark' ],
'plum' => [ 'bg' => '#6d28d9', 'text' => '#f5f3ff', 'mode' => 'dark' ],
'ocean' => [ 'bg' => '#0c4a6e', 'text' => '#e0f2fe', 'mode' => 'dark' ],
'charcoal' => [ 'bg' => '#1f2937', 'text' => '#e5e7eb', 'mode' => 'dark' ],
'clay' => [ 'bg' => '#fef3c7', 'text' => '#78350f', 'mode' => 'light' ],
'gold' => [ 'bg' => '#92400e', 'text' => '#fffbeb', 'mode' => 'dark' ],
'mint' => [ 'bg' => '#047857', 'text' => '#ecfdf5', 'mode' => 'dark' ],
'sky' => [ 'bg' => '#0ea5e9', 'text' => '#e0f2fe', 'mode' => 'dark' ],
'berry' => [ 'bg' => '#9d174d', 'text' => '#fff1f2', 'mode' => 'dark' ],
'cocoa' => [ 'bg' => '#4b2f23', 'text' => '#f5f0eb', 'mode' => 'dark' ],
'parchment'=> [ 'bg' => '#fffbeb', 'text' => '#7c2d12', 'mode' => 'light' ],
];
// Default selections when no user choice exists
public $defaultStyle = 'blue';
public $defaultMenu = 'white';
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Debug\Toolbar\Collectors\Database;
use CodeIgniter\Debug\Toolbar\Collectors\Events;
use CodeIgniter\Debug\Toolbar\Collectors\Files;
use CodeIgniter\Debug\Toolbar\Collectors\Logs;
use CodeIgniter\Debug\Toolbar\Collectors\Routes;
use CodeIgniter\Debug\Toolbar\Collectors\Timers;
use CodeIgniter\Debug\Toolbar\Collectors\Views;
/**
* --------------------------------------------------------------------------
* Debug Toolbar
* --------------------------------------------------------------------------
*
* The Debug Toolbar provides a way to see information about the performance
* and state of your application during that page display. By default it will
* NOT be displayed under production environments, and will only display if
* `CI_DEBUG` is true, since if it's not, there's not much to display anyway.
*/
class Toolbar extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Toolbar Collectors
* --------------------------------------------------------------------------
*
* List of toolbar collectors that will be called when Debug Toolbar
* fires up and collects data from.
*
* @var list<class-string>
*/
public array $collectors = [
Timers::class,
Database::class,
Logs::class,
Views::class,
// \CodeIgniter\Debug\Toolbar\Collectors\Cache::class,
Files::class,
Routes::class,
Events::class,
];
/**
* --------------------------------------------------------------------------
* Collect Var Data
* --------------------------------------------------------------------------
*
* If set to false var data from the views will not be collected. Useful to
* avoid high memory usage when there are lots of data passed to the view.
*/
public bool $collectVarData = true;
/**
* --------------------------------------------------------------------------
* Max History
* --------------------------------------------------------------------------
*
* `$maxHistory` sets a limit on the number of past requests that are stored,
* helping to conserve file space used to store them. You can set it to
* 0 (zero) to not have any history stored, or -1 for unlimited history.
*/
public int $maxHistory = 20;
/**
* --------------------------------------------------------------------------
* Toolbar Views Path
* --------------------------------------------------------------------------
*
* The full path to the the views that are used by the toolbar.
* This MUST have a trailing slash.
*/
public string $viewsPath = SYSTEMPATH . 'Debug/Toolbar/Views/';
/**
* --------------------------------------------------------------------------
* Max Queries
* --------------------------------------------------------------------------
*
* If the Database Collector is enabled, it will log every query that the
* the system generates so they can be displayed on the toolbar's timeline
* and in the query log. This can lead to memory issues in some instances
* with hundreds of queries.
*
* `$maxQueries` defines the maximum amount of queries that will be stored.
*/
public int $maxQueries = 100;
/**
* --------------------------------------------------------------------------
* Watched Directories
* --------------------------------------------------------------------------
*
* Contains an array of directories that will be watched for changes and
* used to determine if the hot-reload feature should reload the page or not.
* We restrict the values to keep performance as high as possible.
*
* NOTE: The ROOTPATH will be prepended to all values.
*
* @var list<string>
*/
public array $watchedDirectories = [
'app',
];
/**
* --------------------------------------------------------------------------
* Watched File Extensions
* --------------------------------------------------------------------------
*
* Contains an array of file extensions that will be watched for changes and
* used to determine if the hot-reload feature should reload the page or not.
*
* @var list<string>
*/
public array $watchedExtensions = [
'php', 'css', 'js', 'html', 'svg', 'json', 'env',
];
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* -------------------------------------------------------------------
* User Agents
* -------------------------------------------------------------------
*
* This file contains four arrays of user agent data. It is used by the
* User Agent Class to help identify browser, platform, robot, and
* mobile device data. The array keys are used to identify the device
* and the array values are used to set the actual name of the item.
*/
class UserAgents extends BaseConfig
{
/**
* -------------------------------------------------------------------
* OS Platforms
* -------------------------------------------------------------------
*
* @var array<string, string>
*/
public array $platforms = [
'windows nt 10.0' => 'Windows 10',
'windows nt 6.3' => 'Windows 8.1',
'windows nt 6.2' => 'Windows 8',
'windows nt 6.1' => 'Windows 7',
'windows nt 6.0' => 'Windows Vista',
'windows nt 5.2' => 'Windows 2003',
'windows nt 5.1' => 'Windows XP',
'windows nt 5.0' => 'Windows 2000',
'windows nt 4.0' => 'Windows NT 4.0',
'winnt4.0' => 'Windows NT 4.0',
'winnt 4.0' => 'Windows NT',
'winnt' => 'Windows NT',
'windows 98' => 'Windows 98',
'win98' => 'Windows 98',
'windows 95' => 'Windows 95',
'win95' => 'Windows 95',
'windows phone' => 'Windows Phone',
'windows' => 'Unknown Windows OS',
'android' => 'Android',
'blackberry' => 'BlackBerry',
'iphone' => 'iOS',
'ipad' => 'iOS',
'ipod' => 'iOS',
'os x' => 'Mac OS X',
'ppc mac' => 'Power PC Mac',
'freebsd' => 'FreeBSD',
'ppc' => 'Macintosh',
'linux' => 'Linux',
'debian' => 'Debian',
'sunos' => 'Sun Solaris',
'beos' => 'BeOS',
'apachebench' => 'ApacheBench',
'aix' => 'AIX',
'irix' => 'Irix',
'osf' => 'DEC OSF',
'hp-ux' => 'HP-UX',
'netbsd' => 'NetBSD',
'bsdi' => 'BSDi',
'openbsd' => 'OpenBSD',
'gnu' => 'GNU/Linux',
'unix' => 'Unknown Unix OS',
'symbian' => 'Symbian OS',
];
/**
* -------------------------------------------------------------------
* Browsers
* -------------------------------------------------------------------
*
* The order of this array should NOT be changed. Many browsers return
* multiple browser types so we want to identify the subtype first.
*
* @var array<string, string>
*/
public array $browsers = [
'OPR' => 'Opera',
'Flock' => 'Flock',
'Edge' => 'Spartan',
'Edg' => 'Edge',
'Chrome' => 'Chrome',
// Opera 10+ always reports Opera/9.80 and appends Version/<real version> to the user agent string
'Opera.*?Version' => 'Opera',
'Opera' => 'Opera',
'MSIE' => 'Internet Explorer',
'Internet Explorer' => 'Internet Explorer',
'Trident.* rv' => 'Internet Explorer',
'Shiira' => 'Shiira',
'Firefox' => 'Firefox',
'Chimera' => 'Chimera',
'Phoenix' => 'Phoenix',
'Firebird' => 'Firebird',
'Camino' => 'Camino',
'Netscape' => 'Netscape',
'OmniWeb' => 'OmniWeb',
'Safari' => 'Safari',
'Mozilla' => 'Mozilla',
'Konqueror' => 'Konqueror',
'icab' => 'iCab',
'Lynx' => 'Lynx',
'Links' => 'Links',
'hotjava' => 'HotJava',
'amaya' => 'Amaya',
'IBrowse' => 'IBrowse',
'Maxthon' => 'Maxthon',
'Ubuntu' => 'Ubuntu Web Browser',
'Vivaldi' => 'Vivaldi',
];
/**
* -------------------------------------------------------------------
* Mobiles
* -------------------------------------------------------------------
*
* @var array<string, string>
*/
public array $mobiles = [
// legacy array, old values commented out
'mobileexplorer' => 'Mobile Explorer',
// 'openwave' => 'Open Wave',
// 'opera mini' => 'Opera Mini',
// 'operamini' => 'Opera Mini',
// 'elaine' => 'Palm',
'palmsource' => 'Palm',
// 'digital paths' => 'Palm',
// 'avantgo' => 'Avantgo',
// 'xiino' => 'Xiino',
'palmscape' => 'Palmscape',
// 'nokia' => 'Nokia',
// 'ericsson' => 'Ericsson',
// 'blackberry' => 'BlackBerry',
// 'motorola' => 'Motorola'
// Phones and Manufacturers
'motorola' => 'Motorola',
'nokia' => 'Nokia',
'palm' => 'Palm',
'iphone' => 'Apple iPhone',
'ipad' => 'iPad',
'ipod' => 'Apple iPod Touch',
'sony' => 'Sony Ericsson',
'ericsson' => 'Sony Ericsson',
'blackberry' => 'BlackBerry',
'cocoon' => 'O2 Cocoon',
'blazer' => 'Treo',
'lg' => 'LG',
'amoi' => 'Amoi',
'xda' => 'XDA',
'mda' => 'MDA',
'vario' => 'Vario',
'htc' => 'HTC',
'samsung' => 'Samsung',
'sharp' => 'Sharp',
'sie-' => 'Siemens',
'alcatel' => 'Alcatel',
'benq' => 'BenQ',
'ipaq' => 'HP iPaq',
'mot-' => 'Motorola',
'playstation portable' => 'PlayStation Portable',
'playstation 3' => 'PlayStation 3',
'playstation vita' => 'PlayStation Vita',
'hiptop' => 'Danger Hiptop',
'nec-' => 'NEC',
'panasonic' => 'Panasonic',
'philips' => 'Philips',
'sagem' => 'Sagem',
'sanyo' => 'Sanyo',
'spv' => 'SPV',
'zte' => 'ZTE',
'sendo' => 'Sendo',
'nintendo dsi' => 'Nintendo DSi',
'nintendo ds' => 'Nintendo DS',
'nintendo 3ds' => 'Nintendo 3DS',
'wii' => 'Nintendo Wii',
'open web' => 'Open Web',
'openweb' => 'OpenWeb',
// Operating Systems
'android' => 'Android',
'symbian' => 'Symbian',
'SymbianOS' => 'SymbianOS',
'elaine' => 'Palm',
'series60' => 'Symbian S60',
'windows ce' => 'Windows CE',
// Browsers
'obigo' => 'Obigo',
'netfront' => 'Netfront Browser',
'openwave' => 'Openwave Browser',
'mobilexplorer' => 'Mobile Explorer',
'operamini' => 'Opera Mini',
'opera mini' => 'Opera Mini',
'opera mobi' => 'Opera Mobile',
'fennec' => 'Firefox Mobile',
// Other
'digital paths' => 'Digital Paths',
'avantgo' => 'AvantGo',
'xiino' => 'Xiino',
'novarra' => 'Novarra Transcoder',
'vodafone' => 'Vodafone',
'docomo' => 'NTT DoCoMo',
'o2' => 'O2',
// Fallback
'mobile' => 'Generic Mobile',
'wireless' => 'Generic Mobile',
'j2me' => 'Generic Mobile',
'midp' => 'Generic Mobile',
'cldc' => 'Generic Mobile',
'up.link' => 'Generic Mobile',
'up.browser' => 'Generic Mobile',
'smartphone' => 'Generic Mobile',
'cellphone' => 'Generic Mobile',
];
/**
* -------------------------------------------------------------------
* Robots
* -------------------------------------------------------------------
*
* There are hundred of bots but these are the most common.
*
* @var array<string, string>
*/
public array $robots = [
'googlebot' => 'Googlebot',
'msnbot' => 'MSNBot',
'baiduspider' => 'Baiduspider',
'bingbot' => 'Bing',
'slurp' => 'Inktomi Slurp',
'yahoo' => 'Yahoo',
'ask jeeves' => 'Ask Jeeves',
'fastcrawler' => 'FastCrawler',
'infoseek' => 'InfoSeek Robot 1.0',
'lycos' => 'Lycos',
'yandex' => 'YandexBot',
'mediapartners-google' => 'MediaPartners Google',
'CRAZYWEBCRAWLER' => 'Crazy Webcrawler',
'adsbot-google' => 'AdsBot Google',
'feedfetcher-google' => 'Feedfetcher Google',
'curious george' => 'Curious George',
'ia_archiver' => 'Alexa Crawler',
'MJ12bot' => 'Majestic-12',
'Uptimebot' => 'Uptimebot',
];
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Validation\StrictRules\CreditCardRules;
use CodeIgniter\Validation\StrictRules\FileRules;
use CodeIgniter\Validation\StrictRules\FormatRules;
use CodeIgniter\Validation\StrictRules\Rules;
class Validation extends BaseConfig
{
// --------------------------------------------------------------------
// Setup
// --------------------------------------------------------------------
/**
* Stores the classes that contain the
* rules that are available.
*
* @var list<string>
*/
public array $ruleSets = [
Rules::class,
FormatRules::class,
FileRules::class,
CreditCardRules::class,
];
/**
* Specifies the views that are used to display the
* errors.
*
* @var array<string, string>
*/
public array $templates = [
'list' => 'CodeIgniter\Validation\Views\list',
'single' => 'CodeIgniter\Validation\Views\single',
];
// --------------------------------------------------------------------
// Rules
// --------------------------------------------------------------------
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Config;
use CodeIgniter\Config\View as BaseView;
use CodeIgniter\View\ViewDecoratorInterface;
/**
* @phpstan-type parser_callable (callable(mixed): mixed)
* @phpstan-type parser_callable_string (callable(mixed): mixed)&string
*/
class View extends BaseView
{
/**
* When false, the view method will clear the data between each
* call. This keeps your data safe and ensures there is no accidental
* leaking between calls, so you would need to explicitly pass the data
* to each view. You might prefer to have the data stick around between
* calls so that it is available to all views. If that is the case,
* set $saveData to true.
*
* @var bool
*/
public $saveData = true;
/**
* Parser Filters map a filter name with any PHP callable. When the
* Parser prepares a variable for display, it will chain it
* through the filters in the order defined, inserting any parameters.
* To prevent potential abuse, all filters MUST be defined here
* in order for them to be available for use within the Parser.
*
* Examples:
* { title|esc(js) }
* { created_on|date(Y-m-d)|esc(attr) }
*
* @var array<string, string>
* @phpstan-var array<string, parser_callable_string>
*/
public $filters = [];
/**
* Parser Plugins provide a way to extend the functionality provided
* by the core Parser by creating aliases that will be replaced with
* any callable. Can be single or tag pair.
*
* @var array<string, callable|list<string>|string>
* @phpstan-var array<string, list<parser_callable_string>|parser_callable_string|parser_callable>
*/
public $plugins = [];
/**
* View Decorators are class methods that will be run in sequence to
* have a chance to alter the generated output just prior to caching
* the results.
*
* All classes must implement CodeIgniter\View\ViewDecoratorInterface
*
* @var list<class-string<ViewDecoratorInterface>>
*/
public array $decorators = [];
}
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Controllers;
use App\Models\ClassProgressReportModel;
use App\Models\ClassProgressAttachmentModel;
use App\Models\ClassSectionModel;
use App\Models\StudentClassModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\ResponseInterface;
class AdminProgressController extends BaseController
{
protected ClassProgressReportModel $reportModel;
protected ClassProgressAttachmentModel $attachmentModel;
protected ClassSectionModel $classSectionModel;
protected StudentClassModel $studentClassModel;
public function __construct()
{
helper(['url', 'form']);
$this->reportModel = new ClassProgressReportModel();
$this->attachmentModel = new ClassProgressAttachmentModel();
$this->classSectionModel = new ClassSectionModel();
$this->studentClassModel = new StudentClassModel();
}
public function index()
{
$filters = [
'from' => (string) $this->request->getGet('from'),
'to' => (string) $this->request->getGet('to'),
'class_section_id' => (string) $this->request->getGet('class_section_id'),
'status' => (string) $this->request->getGet('status'),
];
$builder = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
if ($filters['from']) {
$builder->where('week_start >=', $filters['from']);
}
if ($filters['to']) {
$builder->where('week_end <=', $filters['to']);
}
if ($filters['class_section_id']) {
$builder->where('class_progress_reports.class_section_id', (int) $filters['class_section_id']);
}
if ($filters['status']) {
$builder->where('class_progress_reports.status', $filters['status']);
}
$rows = $builder->orderBy('week_start', 'DESC')->get()->getResultArray();
$reportGroups = [];
foreach ($rows as $row) {
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? '');
if ($key === '_') {
continue;
}
if (! isset($reportGroups[$key])) {
$reportGroups[$key] = [
'week_start' => $row['week_start'],
'week_end' => $row['week_end'],
'class_section_name' => $row['class_section_name'] ?? '',
'reports' => [],
];
}
$reportGroups[$key]['reports'][$row['subject']] = $row;
}
$classSections = $this->classSectionModel->getClassSections();
$studentCounts = $this->studentClassModel->getStudentCountsBySection();
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
$sectionId = (int) ($section['class_section_id'] ?? 0);
return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0;
}));
return view('admin/class_progress_list', [
'reportGroups' => $reportGroups,
'filters' => $filters,
'classSections' => $filteredSections,
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
]);
}
public function view($id)
{
$row = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
->find((int) $id);
if (! $row) {
throw new PageNotFoundException('Progress report not found.');
}
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$row['flags'] = $this->decodeFlags($row['flags_json']);
$weeklyReports = $this->reportModel
->select('class_progress_reports.*')
->where('class_section_id', $row['class_section_id'])
->where('week_start', $row['week_start'])
->orderBy('subject', 'ASC')
->findAll();
$attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id'));
foreach ($weeklyReports as &$report) {
$report['attachments'] = $attachmentMap[$report['id']] ?? [];
if (empty($report['attachments']) && ! empty($report['attachment_path'])) {
$report['attachments'][] = [
'id' => $report['id'],
'name' => basename((string) $report['attachment_path']),
'legacy' => true,
];
}
}
return view('admin/class_progress_view', [
'row' => $row,
'weeklyReports' => $weeklyReports,
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
]);
}
public function attachment($id)
{
$row = $this->reportModel->find((int)$id);
if (! $row || empty($row['attachment_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentFile($row);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
return $this->response->download($file, null)->setFileName(basename($file));
}
public function attachmentFile($id)
{
$attachment = $this->attachmentModel->find((int) $id);
if (! $attachment || empty($attachment['file_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentPath($attachment['file_path']);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
$downloadName = $attachment['original_name'] ?: basename($file);
return $this->response->download($file, null)->setFileName($downloadName);
}
protected function resolveAttachmentFile(array $row): ?string
{
$path = trim((string) ($row['attachment_path'] ?? ''));
if ($path === '') {
return null;
}
return $this->resolveAttachmentPath($path);
}
protected function resolveAttachmentPath(string $path): ?string
{
$relative = preg_replace('#^writable/uploads/#', '', $path);
$absolute = WRITEPATH . 'uploads/' . ltrim($relative, '/');
return is_file($absolute) ? $absolute : null;
}
protected function loadAttachmentsForReports(array $reportIds): array
{
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
if (empty($reportIds)) {
return [];
}
$rows = $this->attachmentModel
->whereIn('report_id', $reportIds)
->orderBy('id', 'ASC')
->findAll();
$map = [];
foreach ($rows as $row) {
$reportId = (int) ($row['report_id'] ?? 0);
if ($reportId === 0) {
continue;
}
$map[$reportId][] = [
'id' => (int) $row['id'],
'name' => $row['original_name'] ?: basename((string) $row['file_path']),
];
}
return $map;
}
protected function decodeFlags(?string $json): array
{
if (! $json) {
return [];
}
$flags = json_decode($json, true);
return is_array($flags) ? $flags : [];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
class ApiDocsController extends Controller
{
public function index()
{
return view('docs/swagger_ui');
}
public function public()
{
return view('docs/swagger_ui_public');
}
}
+614
View File
@@ -0,0 +1,614 @@
<?php
namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
use CodeIgniter\I18n\Time;
use App\Models\RoleModel;
use App\Models\ConfigurationModel;
use App\Models\PreferencesModel;
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends Controller
{
protected $configModel;
protected $userModel;
protected $roleModel;
protected $schoolYear;
protected $semester;
protected $ipAttemptModel;
protected $loginActivityModel;
protected $preferencesModel;
public function __construct()
{
$this->userModel = new UserModel();
$this->roleModel = new RoleModel();
$this->configModel = new ConfigurationModel();
$this->ipAttemptModel = new IpAttemptModel();
$this->loginActivityModel = new LoginActivityModel();
$this->preferencesModel = new PreferencesModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
}
public function setModel($model)
{
$this->userModel = $model;
}
private function scheduleEvent($userId)
{
// Schedule the event to run after 2 minutes (120 seconds)
Events::trigger('delete_unverified_user', $userId);
}
public function loginMask()
{
// Serve the login view directly here
return view('user/login'); // Adjust the view path as needed
}
public function login()
{
log_message('info', 'Processing login form submission.');
// Step 1: Get email, password, and IP from the request
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$ip = $this->request->getIPAddress();
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
// Step 2: Check if the IP is blocked (too many failed attempts)
if ($this->isIpBlocked($ip)) {
return redirect()
->back()
->with('error', 'Too many failed attempts from your IP. Please try again later.')
->withInput(); // ✅ preserve email input
}
// Step 3: Look up user by email
$user = $this->getUserByEmail($email);
if ($user) {
// Step 4: Check if user is suspended
if ($this->checkIfUserSuspended($user)) {
return redirect()
->back()
->with('error', 'Account suspended. Please check your email to reset your password.')
->withInput(); // ✅ preserve email input
}
// Step 5: Verify password
if ($this->verifyPassword($password, $user['password'])) {
// Step 6: Login successful — reset failed attempts and log
$this->resetFailedAttempts($user['id']);
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
// ✅ Step 7: Call loginUser() to set session and redirect
return $this->loginUser($user);
} else {
// Step 8: Password mismatch — log failed attempt
$this->handleFailedLogin($user['id'], $user['email'], $ip);
return redirect()
->back()
->with('error', 'The email and password combination you entered is invalid. Please try again.')
->withInput(); // ✅ preserve email input
}
} else {
// Step 9: No user found — log IP-level attempt
$this->logIpAttempt($ip);
return redirect()
->back()
->with('error', 'The email and password combination you entered is invalid. Please try again.')
->withInput(); // ✅ preserve email input
}
}
// JSON API: POST /api/login
public function apiLogin()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
}
$email = $requestData['email'] ?? '';
$password = $requestData['password'] ?? '';
$ip = $this->request->getIPAddress();
if (!$email || !$password) {
return $this->response->setStatusCode(400)->setJSON([
'status' => false,
'message' => 'Email and password are required.'
]);
}
if ($this->isIpBlocked($ip)) {
return $this->response->setStatusCode(429)->setJSON([
'status' => false,
'message' => 'Too many failed attempts from your IP. Please try again later.'
]);
}
$user = $this->getUserByEmail($email);
if (!$user) {
$this->logIpAttempt($ip);
return $this->response->setStatusCode(401)->setJSON([
'status' => false,
'message' => 'Invalid email or password.'
]);
}
if ($this->checkIfUserSuspended($user)) {
return $this->response->setStatusCode(403)->setJSON([
'status' => false,
'message' => 'Account suspended. Please reset your password.'
]);
}
if (!$this->verifyPassword($password, $user['password'])) {
$this->handleFailedLogin($user['id'], $user['email'], $ip);
return $this->response->setStatusCode(401)->setJSON([
'status' => false,
'message' => 'Invalid email or password.'
]);
}
// Success: reset attempts + log
$this->resetFailedAttempts($user['id']);
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
// Fetch roles
$userRoleModel = new UserRoleModel();
$rolesRows = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $user['id'])
->get()
->getResultArray();
$roleNames = array_column($rolesRows, 'name');
// Build roles map (object with keys per example)
$rolesMap = [];
foreach ($roleNames as $r) {
$rolesMap[$r] = true;
}
// Build JWT token
$now = time();
$exp = $now + 60 * 60 * 24; // 24h default
$payload = [
'sub' => (int) $user['id'],
'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')),
'roles' => $roleNames,
'iat' => $now,
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setJSON([
'status' => true,
'token' => $token,
'user' => [
'id' => (int) $user['id'],
'name' => $payload['name'],
'roles' => (object) $rolesMap,
],
]);
}
/**
* API Registration endpoint
* POST /api/v1/register
*/
public function apiRegister()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = $this->request->getPost();
}
// Basic validation
$rules = [
'firstname' => 'required|min_length[2]|max_length[30]',
'lastname' => 'required|min_length[2]|max_length[30]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]',
'cellphone' => 'required|min_length[10]|max_length[20]',
];
$validation = \Config\Services::validation();
$validation->setRules($rules);
if (!$validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Validation failed',
'errors' => $validation->getErrors(),
]);
}
// Check if email already exists
$existingUser = $this->userModel->where('email', $requestData['email'])->first();
if ($existingUser) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Email already registered',
]);
}
// Hash password
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
$hashedPassword = pbkdf2_hash($requestData['password']);
// Prepare user data
$userData = [
'firstname' => $requestData['firstname'],
'lastname' => $requestData['lastname'],
'email' => $requestData['email'],
'password' => $hashedPassword,
'cellphone' => $requestData['cellphone'],
'gender' => $requestData['gender'] ?? null,
'address_street' => $requestData['address_street'] ?? null,
'city' => $requestData['city'] ?? null,
'state' => $requestData['state'] ?? null,
'zip' => $requestData['zip'] ?? null,
'school_year' => $this->configModel->getConfig('school_year'),
'semester' => $this->configModel->getConfig('semester'),
'status' => 'active',
'is_verified' => 0, // Require email verification
];
try {
$userId = $this->userModel->insert($userData);
if (!$userId) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'Failed to create user account',
'errors' => $this->userModel->errors(),
]);
}
// Assign parent role if specified
$roleId = null;
if (isset($requestData['role']) && $requestData['role'] === 'parent') {
$role = $this->roleModel->where('name', 'parent')->first();
if ($role) {
$roleId = $role['id'];
$userRoleModel = new \App\Models\UserRoleModel();
$userRoleModel->insert([
'user_id' => $userId,
'role_id' => $roleId,
]);
}
}
// Generate JWT token
$now = time();
$exp = $now + 60 * 60 * 24; // 24h
$payload = [
'sub' => (int) $userId,
'name' => trim($userData['firstname'] . ' ' . $userData['lastname']),
'roles' => $roleId ? ['parent'] : [],
'iat' => $now,
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setStatusCode(201)->setJSON([
'status' => true,
'message' => 'Registration successful. Please verify your email.',
'token' => $token,
'user' => [
'id' => (int) $userId,
'name' => $payload['name'],
'email' => $userData['email'],
'roles' => $payload['roles'],
],
]);
} catch (\Exception $e) {
log_message('error', 'Registration error: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'Registration failed. Please try again.',
]);
}
}
private function verifyPassword($password, $storedHash)
{
if (!function_exists('pbkdf2_verify')) {
die('pbkdf2_verify() is NOT loaded');
}
return pbkdf2_verify($password, $storedHash);
}
private function handleFailedLogin($userId, $email, $ip)
{
// Get user data
$user = $this->userModel->find($userId);
$failedAttempts = $user['failed_attempts'] + 1;
$data = ['failed_attempts' => $failedAttempts, 'last_failed_at' => utc_now()];
// Check if the failed attempts have reached 3
if ($failedAttempts >= 3) {
// Suspend the account
$data['is_suspended'] = true;
// Set a warning message in session to inform the user
session()->setFlashdata('warning_message', 'Your account has been suspended due to multiple failed login attempts. A reset password email has been sent to your registered email address.');
// Send the password reset email
$this->sendPasswordResetEmail($email);
}
// Update the user record
$this->userModel->update($userId, $data);
// Log the IP attempt (track failed login attempts)
$this->logIpAttempt($ip);
}
private function isIpBlocked($ip)
{
$attempt = $this->ipAttemptModel->where('ip_address', $ip)->first();
return $attempt && $attempt['blocked_until'] > utc_now();
}
private function logIpAttempt($ip)
{
$now = utc_now();
$attempt = $this->ipAttemptModel->where('ip_address', $ip)->first();
if ($attempt) {
$attempts = $attempt['attempts'] + 1;
$blockedUntil = $attempts >= 10 ? date('Y-m-d H:i:s', strtotime('+24 hours')) : null;
$this->ipAttemptModel->update($attempt['id'], [
'attempts' => $attempts,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil
]);
} else {
$this->ipAttemptModel->insert([
'ip_address' => $ip,
'attempts' => 1,
'last_attempt_at' => $now
]);
}
}
private function logLoginAttempt($userId, $email, $ipAddress, $userAgent)
{
$this->loginActivityModel->insert([
'user_id' => $userId,
'email' => $email,
'login_time' => utc_now(),
'ip_address' => $ipAddress,
'user_agent' => $userAgent
]);
}
private function resetFailedAttempts($userId)
{
$this->userModel->update($userId, ['failed_attempts' => 0, 'last_failed_at' => null]);
}
public function logout()
{
// Get user ID and email from session
$userId = session()->get('user_id');
$email = session()->get('user_email');
// Log logout
$this->logLogout($userId, $email);
log_message('info', 'Session destroyed.');
// Destroy the session
session()->destroy();
// Redirect to the main login page
return redirect()->to(base_url('/'));
}
private function logLogout($userId, $email)
{
$this->loginActivityModel->where('user_id', $userId)
->where('logout_time', null)
->set('logout_time', utc_now())
->set('email', $email)
->update();
}
private function getUserByEmail($email)
{
return $this->userModel->where('email', $email)->first();
}
private function checkIfUserSuspended($user)
{
return $user['is_suspended'];
}
// This function is used to send ResetPassword email to the userwith suspended account.
private function sendPasswordResetEmail($email)
{
// Load the UserController
$userController = new \App\Controllers\View\UserController();
// First, attempt to find the user by their email
$userModel = new UserModel();
$user = $userModel->where('email', $email)->first();
if (!$user) {
log_message('error', 'User with email ' . $email . ' not found for password reset.');
return false;
}
// Generate a secure token for the password reset
helper('text');
$token = bin2hex(random_bytes(48));
// Calculate the expiration time for the token (1 hour from now)
$expires_at = Time::now()->addHours(1);
// Store the token in the password_resets table
$passwordResetModel = new PasswordResetModel();
$passwordResetModel->insert([
'email' => $email,
'token' => $token,
'created_at' => Time::now(),
'expires_at' => $expires_at,
]);
// Now, send the reset email using the generated token
$userController->sendResetEmail($email, $token); // Calling the original helper function to send the email
// Log and return success
log_message('info', 'Password reset email sent to ' . $email);
return true;
}
private function loginUser($user)
{
$userRoleModel = new UserRoleModel();
$roles = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $user['id'])
->get()
->getResultArray();
if (empty($roles)) {
log_message('error', 'No roles found for user ID: ' . $user['id']);
return redirect()->back()->with('error', 'Role not assigned. Please contact support.');
}
$roleNames = array_column($roles, 'name');
session()->set([
'user_id' => $user['id'],
'user_email' => $user['email'],
'user_name' => $user['firstname'] . ' ' . $user['lastname'],
'user_type' => $user['user_type'],
'is_logged_in' => true,
'login_time' => time(),
'roles' => $roleNames,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
$this->applyStylePreferences((int) $user['id']);
log_message('debug', 'Session after login: ' . print_r(session()->get(), true));
//dd('Login successful. Roles:', $roleNames, session()->get());
if (count($roleNames) === 1) {
// One role → set and redirect directly
session()->set('role', $roleNames[0]);
return $this->redirectToDashboard([$roleNames[0]]);
} else {
// Multiple roles → redirect to role selection view
return redirect()->to('/select-role');
}
}
private function applyStylePreferences(int $userId): void
{
if (!$userId) {
return;
}
$prefs = $this->preferencesModel->where('user_id', $userId)->first();
if (!$prefs) {
return;
}
$styleCfg = config('Style');
$styleColor = (string) ($prefs['style_color'] ?? '');
if ($styleColor !== '' && isset(($styleCfg->stylePalettes ?? [])[$styleColor])) {
session()->set('style_color', $styleColor);
}
$menuColor = (string) ($prefs['menu_color'] ?? '');
if ($menuColor === 'custom') {
$bg = (string) ($prefs['menu_custom_bg'] ?? '#0f172a');
$tx = (string) ($prefs['menu_custom_text'] ?? '#ffffff');
$mode = (string) ($prefs['menu_custom_mode'] ?? 'dark');
session()->set('menu_color', 'custom');
session()->set('menu_custom_bg', $bg);
session()->set('menu_custom_text', $tx);
session()->set('menu_custom_mode', $mode);
} elseif ($menuColor !== '' && isset(($styleCfg->menuPalettes ?? [])[$menuColor])) {
session()->set('menu_color', $menuColor);
}
}
private function redirectToDashboard(array $roles)
{
if (empty($roles)) {
log_message('error', 'Empty roles array passed to redirectToDashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
$roleModel = new RoleModel();
// Resolve all candidate roles (by name or slug), ordered by priority ASC
$rows = $roleModel->findByNamesOrSlugs($roles);
if (!empty($rows)) {
$route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
log_message('debug', 'Redirecting user to: ' . $route);
return redirect()->to($route);
}
log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
public function setRole()
{
$selectedRole = $this->request->getPost('selected_role');
$availableRoles = session()->get('roles');
if (!$selectedRole || !in_array($selectedRole, $availableRoles)) {
return redirect()->to('/select-role')->with('error', 'Invalid role selected.');
}
session()->set('role', $selectedRole);
return $this->redirectToDashboard([$selectedRole]);
}
public function selectRole()
{
$roles = session()->get('roles');
if (!$roles || !is_array($roles)) {
return redirect()->to('/login')->with('error', 'No roles available.');
}
return view('auth/select_role', ['roles' => $roles]);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\CLIRequest;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
use App\Services\ApiClient;
/**
* Class BaseController
*
* BaseController provides a convenient place for loading components
* and performing functions that are needed by all your controllers.
* Extend this class in any new controllers:
* class Home extends BaseController
*
* For security be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Instance of the main Request object.
*
* @var CLIRequest|IncomingRequest
*/
protected $request;
/**
* An array of helpers to be loaded automatically upon
* class instantiation. These helpers will be available
* to all other controllers that extend BaseController.
*
* @var list<string>
*/
protected $helpers = [];
/** @var ApiClient */
protected ApiClient $api;
/**
* Be sure to declare properties for any property fetch you initialized.
* The creation of dynamic property is deprecated in PHP 8.2.
*/
// protected $session;
/**
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// Do Not Edit This Line
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
// E.g.: $this->session = \Config\Services::session();
session();
// Shared API client available to all controllers
$this->api = service('apiClient');
}
/**
* Get the user role from the session or database
*
* @return string|null
*/
protected function getUserRole()
{
// Assuming the user role is stored in the session
return session()->get('role');
}
}
?>
+471
View File
@@ -0,0 +1,471 @@
<?php
namespace App\Controllers;
use App\Models\ClassProgressReportModel;
use App\Models\ClassProgressAttachmentModel;
use App\Models\ConfigurationModel;
use App\Models\SubjectCurriculumModel;
use App\Models\TeacherClassModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class ClassProgressController extends BaseController
{
public const STATUS_OPTIONS = [
'on_track' => 'On track',
'slightly_behind' => 'Slightly behind',
'behind' => 'Behind',
];
private const DEFAULT_STATUS = 'on_track';
public const SUBJECT_SECTIONS = [
'islamic' => [
'label' => 'Islamic Studies',
'db_subject' => 'Islamic Studies',
],
'quran' => [
'label' => 'Quran/Arabic',
'db_subject' => 'Quran/Arabic',
],
];
protected ClassProgressReportModel $reportModel;
protected ClassProgressAttachmentModel $attachmentModel;
protected TeacherClassModel $teacherClassModel;
protected ConfigurationModel $configModel;
protected string $attachmentStoragePath;
protected string $attachmentPublicBase;
protected SubjectCurriculumModel $curriculumModel;
public function __construct()
{
helper(['form', 'url']);
$this->reportModel = new ClassProgressReportModel();
$this->attachmentModel = new ClassProgressAttachmentModel();
$this->teacherClassModel = new TeacherClassModel();
$this->configModel = new ConfigurationModel();
$this->curriculumModel = new SubjectCurriculumModel();
$this->attachmentStoragePath = WRITEPATH . 'uploads/class_material/';
$this->attachmentPublicBase = 'writable/uploads/class_material/';
}
public function create()
{
$teacherId = (int) session()->get('user_id');
$assignments = $this->loadTeacherSections($teacherId);
$first = $assignments[0] ?? null;
$classSectionId = $first['class_section_id'] ?? null;
$classSectionName = $first['class_section_name'] ?? null;
$classId = $first['class_id'] ?? null;
$sundayOptions = $this->buildSundayOptions();
$subjectCurriculum = [];
if ($classId) {
foreach (self::SUBJECT_SECTIONS as $slug => $section) {
$subjectCurriculum[$slug] = $this->curriculumModel->getOptionsForClass((int) $classId, $slug);
}
}
$data = [
'subjectSections' => self::SUBJECT_SECTIONS,
'subjectCurriculum' => $subjectCurriculum,
'classSectionId' => $classSectionId,
'classSectionName' => $classSectionName,
'classId' => $classId,
'sundayOptions' => $sundayOptions,
'defaultWeekStart' => $sundayOptions[0] ?? '',
];
return view('teacher/class_progress_submit', $data);
}
public function store()
{
$subjectSections = self::SUBJECT_SECTIONS;
$rules = [
'class_section_id' => 'required|integer',
'week_start' => 'required|valid_date[Y-m-d]',
'week_end' => 'required|valid_date[Y-m-d]',
'support_needed' => 'permit_empty|string',
'flags' => 'permit_empty',
];
foreach ($subjectSections as $slug => $section) {
$rules["covered_$slug"] = 'required|string';
$rules["homework_$slug"] = 'permit_empty|string';
$rules["unit_{$slug}.*"] = 'permit_empty|string|max_length[120]';
$rules["chapter_{$slug}.*"] = 'permit_empty|string|max_length[120]';
}
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$attachmentErrors = $this->validateAttachmentFiles($subjectSections);
if (! empty($attachmentErrors)) {
return redirect()->back()->withInput()->with('errors', $attachmentErrors);
}
$weekStart = (string) $this->request->getPost('week_start');
$weekEnd = (string) $this->request->getPost('week_end');
if ($weekStart && ! $weekEnd) {
$weekEnd = $this->buildWeekEndFromStart($weekStart);
}
if ($weekStart && $weekEnd && strtotime($weekEnd) < strtotime($weekStart)) {
return redirect()->back()->withInput()->with('error', 'Week end must be the same as or after the week start.');
}
$teacherId = (int) session()->get('user_id');
$classSectionId = $this->request->getPost('class_section_id');
$classSectionId = $classSectionId ? (int) $classSectionId : null;
if (! $classSectionId) {
return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.');
}
$status = self::DEFAULT_STATUS;
$reportsCreated = 0;
foreach ($subjectSections as $slug => $section) {
$covered = trim((string) $this->request->getPost("covered_$slug"));
if ($covered === '') {
continue;
}
$homework = trim((string) $this->request->getPost("homework_$slug"));
$unitTitle = $this->buildUnitChapterSummary($slug);
$data = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'week_start' => $weekStart,
'week_end' => $weekEnd,
'subject' => $section['db_subject'] ?? $section['label'] ?? $slug,
'unit_title' => $unitTitle,
'covered' => $covered,
'homework' => $homework ?: null,
'status' => $status,
'flags_json' => $this->normalizeFlags($this->request->getPost('flags')),
];
$reportId = $this->reportModel->insert($data, true);
$attachmentField = "attachment_$slug";
$attachments = $this->request->getFileMultiple($attachmentField) ?? [];
$storedAttachments = $this->storeAttachments($reportId, $attachments);
if (! empty($storedAttachments)) {
$this->attachmentModel->insertBatch($storedAttachments);
$this->reportModel->update($reportId, ['attachment_path' => $storedAttachments[0]['file_path']]);
}
$reportsCreated++;
}
if ($reportsCreated === 0) {
return redirect()->back()->withInput()->with('error', 'Please provide progress for at least one subject.');
}
return redirect()->to('teacher/progress/submit')->with('success', 'Progress reports saved.');
}
public function history()
{
$teacherId = (int) session()->get('user_id');
$assignments = $this->loadTeacherSections($teacherId);
$selectedSectionId = (int) $this->request->getGet('class_section_id');
$validSectionIds = array_column($assignments, 'class_section_id');
if ($selectedSectionId === 0 && ! empty($validSectionIds)) {
$selectedSectionId = $validSectionIds[0];
}
if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) {
$selectedSectionId = $validSectionIds[0] ?? null;
}
$builder = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->where('teacher_id', $teacherId);
if ($selectedSectionId) {
$builder->where('class_progress_reports.class_section_id', $selectedSectionId);
}
$rows = $builder
->orderBy('week_start', 'DESC')
->findAll();
$reportGroups = [];
foreach ($rows as $row) {
$row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$key = $row['week_start'] ?? '';
if ($key === '') {
continue;
}
if (! isset($reportGroups[$key])) {
$reportGroups[$key] = [
'week_start' => $row['week_start'],
'week_end' => $row['week_end'],
'class_section_name' => $row['class_section_name'] ?? '',
'reports' => [],
];
}
$reportGroups[$key]['reports'][$row['subject']] = $row;
}
$sectionOptions = [];
foreach ($assignments as $assignment) {
$sectionOptions[$assignment['class_section_id']] = $assignment['class_section_name'] ?? '';
}
return view('teacher/class_progress_history', [
'reportGroups' => $reportGroups,
'subjectSections' => self::SUBJECT_SECTIONS,
'classSectionOptions' => $sectionOptions,
'selectedSectionId' => $selectedSectionId,
]);
}
public function view($id)
{
$teacherId = (int) session()->get('user_id');
$row = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->where('teacher_id', $teacherId)
->find((int) $id);
if (! $row) {
throw new PageNotFoundException('Progress report not found.');
}
$row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$weeklyReports = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->where('teacher_id', $teacherId)
->where('class_progress_reports.class_section_id', $row['class_section_id'])
->where('week_start', $row['week_start'])
->orderBy('subject', 'ASC')
->findAll();
$attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id'));
foreach ($weeklyReports as &$report) {
$report['status_label'] = self::STATUS_OPTIONS[$report['status']] ?? 'Unknown';
$report['attachments'] = $attachmentMap[$report['id']] ?? [];
if (empty($report['attachments']) && ! empty($report['attachment_path'])) {
$report['attachments'][] = [
'id' => $report['id'],
'name' => basename((string) $report['attachment_path']),
'legacy' => true,
];
}
}
return view('teacher/class_progress_view', [
'row' => $row,
'weeklyReports' => $weeklyReports,
'subjectSections' => self::SUBJECT_SECTIONS,
]);
}
public function attachment($id)
{
$row = $this->reportModel->find((int)$id);
if (! $row || empty($row['attachment_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentFile($row);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
return $this->response->download($file, null)->setFileName(basename($file));
}
public function attachmentFile($id)
{
$attachment = $this->attachmentModel->find((int) $id);
if (! $attachment || empty($attachment['file_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentPath($attachment['file_path']);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
$downloadName = $attachment['original_name'] ?: basename($file);
return $this->response->download($file, null)->setFileName($downloadName);
}
protected function resolveAttachmentFile(array $row): ?string
{
$path = trim((string) ($row['attachment_path'] ?? ''));
if ($path === '') {
return null;
}
return $this->resolveAttachmentPath($path);
}
protected function resolveAttachmentPath(string $path): ?string
{
$relative = preg_replace('#^writable/uploads/#', '', $path);
$absolute = WRITEPATH . 'uploads/' . ltrim($relative, '/');
return is_file($absolute) ? $absolute : null;
}
protected function validateAttachmentFiles(array $subjectSections): array
{
$errors = [];
foreach ($subjectSections as $slug => $section) {
$label = $section['label'] ?? $slug;
$field = "attachment_$slug";
$files = $this->request->getFileMultiple($field) ?? [];
foreach ($files as $file) {
if (! $file || $file->getError() === UPLOAD_ERR_NO_FILE) {
continue;
}
if (! $file->isValid()) {
$errors[] = "Invalid attachment uploaded for {$label}.";
continue;
}
if ($file->getSize() > 5 * 1024 * 1024) {
$errors[] = "Each attachment for {$label} must be 5MB or smaller.";
}
$ext = strtolower((string) $file->getClientExtension());
if ($ext === '' || ! in_array($ext, ['pdf', 'jpg', 'jpeg', 'png'], true)) {
$errors[] = "Only PDF, JPG, JPEG, or PNG files are allowed for {$label}.";
}
}
}
return $errors;
}
protected function storeAttachments(int $reportId, array $attachments): array
{
if (empty($attachments)) {
return [];
}
$stored = [];
$now = date('Y-m-d H:i:s');
foreach ($attachments as $file) {
if (! $file || $file->getError() === UPLOAD_ERR_NO_FILE) {
continue;
}
if (! $file->isValid() || $file->hasMoved()) {
continue;
}
$stored[] = [
'report_id' => $reportId,
'file_path' => $this->storeAttachment($file),
'original_name' => $file->getClientName(),
'mime_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
'created_at' => $now,
];
}
return $stored;
}
protected function loadAttachmentsForReports(array $reportIds): array
{
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
if (empty($reportIds)) {
return [];
}
$rows = $this->attachmentModel
->whereIn('report_id', $reportIds)
->orderBy('id', 'ASC')
->findAll();
$map = [];
foreach ($rows as $row) {
$reportId = (int) ($row['report_id'] ?? 0);
if ($reportId === 0) {
continue;
}
$map[$reportId][] = [
'id' => (int) $row['id'],
'name' => $row['original_name'] ?: basename((string) $row['file_path']),
];
}
return $map;
}
protected function loadTeacherSections(int $teacherId): array
{
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
return $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $schoolYear, $semester);
}
protected function normalizeFlags($flags): ?string
{
$flags = array_values(array_filter((array) $flags, static fn ($item) => $item !== '' && $item !== null));
return $flags ? json_encode($flags) : null;
}
protected function buildUnitChapterSummary(string $slug): ?string
{
$unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug"));
$chapterValues = array_map('trim', (array) $this->request->getPost("chapter_$slug"));
$parts = [];
$count = max(count($unitValues), count($chapterValues));
for ($i = 0; $i < $count; $i++) {
$unit = $unitValues[$i] ?? '';
$chapter = $chapterValues[$i] ?? '';
if ($unit === '' && $chapter === '') {
continue;
}
$segment = $unit;
if ($chapter !== '') {
$segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter;
}
if ($segment === '') {
continue;
}
$parts[] = $segment;
}
if (! $parts) {
return null;
}
$summary = implode(' ; ', $parts);
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
}
protected function buildSundayOptions(int $count = 12): array
{
$start = new \DateTime('today');
$weekday = (int) $start->format('w');
if ($weekday !== 0) {
$start->modify('next sunday');
}
$options = [];
for ($i = 0; $i < $count; $i++) {
$options[] = $start->format('Y-m-d');
$start->modify('+7 days');
}
return $options;
}
protected function buildWeekEndFromStart(string $weekStart): string
{
try {
$dt = new \DateTime($weekStart);
$dt->modify('+6 days');
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $weekStart;
}
}
protected function storeAttachment($file): string
{
$this->ensureAttachmentPath();
$name = $file->getRandomName();
$file->move($this->attachmentStoragePath, $name);
return $this->attachmentPublicBase . $name;
}
protected function ensureAttachmentPath(): void
{
if (! is_dir($this->attachmentStoragePath)) {
mkdir($this->attachmentStoragePath, 0755, true);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
class DocsController extends Controller
{
public function index()
{
return view('docs/index');
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Controllers;
class ErrorController extends BaseController
{
public function accessDenied()
{
return $this->response->setStatusCode(403)->setBody(view('errors/access_denied'));
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Controllers;
class Home extends BaseController
{
public function index(): string
{
return view('welcome_message');
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use App\Models\RoleModel;
use App\Models\PermissionModel;
use App\Models\RolePermissionModel;
class InitializeRolesPermissions extends Controller
{
public function index()
{
$roleModel = new RoleModel();
$permissionModel = new PermissionModel();
$rolePermissionModel = new RolePermissionModel();
$roles = [
'administrator', 'principal', 'vice principal', 'admin', 'teacher', 'student', 'parent',
'counselor', 'librarian', 'accountant', 'it support', 'receptionist', 'nurse', 'support staff',
'coach', 'activity coordinator'
];
$permissions = [
'manage_users',
'view_reports',
'manage_settings',
'access_student_records',
'communicate_parents',
'enter_grades',
'access_assignments',
'view_own_records',
'manage_library',
'manage_finances',
'provide_technical_support',
'handle_inquiries',
'provide_medical_assistance',
'maintain_facilities',
'manage_sports_teams',
'organize_activities',
'view_student_attendance',
'manage_teacher_class_assignment',
'conduct_training_sessions',
'handle_disciplinary_actions',
'update_curriculum',
'conduct_examinations',
'manage_transportation',
'oversee_cafeteria_operations',
'manage_alumni_relations',
'conduct_research',
'provide_guidance_counseling',
'supervise_staff',
'coordinate_events',
'manage_social_media',
'handle_emergency_situations',
'monitor_student_progress',
'maintain_security',
'manage_inventory',
'conduct_staff_meetings',
'oversee_academic_advising',
'manage_student_clubs',
'coordinate_volunteer_programs',
'provide_career_services',
'oversee_internship_programs',
'manage_community_outreach',
'maintain_website',
'administer_scholarships',
'manage_student_housing',
'oversee_student_council',
'provide_mental_health_support',
'manage_digital_resources',
'coordinate_field_trips',
'supervise_tutoring_services',
'manage_parking_facilities',
'administer_extracurricular_programs',
'oversee_distance_learning',
'manage_course_registration',
'coordinate_staff_training',
'provide_language_support',
'supervise_after_school_programs',
'handle_student_complaints',
'manage_student_records',
'oversee_financial_aid',
'coordinate_health_services',
'track_student_attendance',
'manage_attendance_reports',
'monitor_class_attendance',
'update_attendance_records',
'handle_attendance_inquiries',
'generate_attendance_statistics',
'communicate_attendance_issues',
'oversee_attendance_policy',
'enforce_attendance_requirements',
'resolve_attendance_discrepancies',
'manage_employee_attendance',
'record_attendance_for_events',
'track_daily_attendance',
'maintain_attendance_logs',
'analyze_attendance_data',
'coordinate_attendance_training',
'report_attendance_to_authorities'
];
foreach ($roles as $role) {
if (!$roleModel->where('name', $role)->first()) {
$roleModel->save(['name' => $role]);
}
}
foreach ($permissions as $permission) {
if (!$permissionModel->where('name', $permission)->first()) {
$permissionModel->save(['name' => $permission]);
}
}
// Associating permissions with roles
$rolePermissions = [
'administrator' => ['manage_users', 'view_reports', 'manage_settings', 'access_student_records', 'communicate_parents'],
'principal' => ['manage_users', 'view_reports', 'access_student_records', 'communicate_parents'],
'vice principal' => ['view_reports', 'access_student_records', 'communicate_parents'],
'teacher' => ['access_student_records', 'communicate_parents', 'enter_grades', 'access_assignments'],
'student' => ['view_own_records', 'access_assignments'],
'parent' => ['view_own_records', 'communicate_parents'],
'counselor' => ['access_student_records', 'communicate_parents'],
'librarian' => ['manage_library'],
'accountant' => ['manage_finances'],
'it support' => ['provide_technical_support'],
'receptionist' => ['handle_inquiries'],
'nurse' => ['provide_medical_assistance'],
'support staff' => ['maintain_facilities'],
'coach' => ['manage_sports_teams'],
'activity coordinator' => ['organize_activities']
];
foreach ($rolePermissions as $role => $permissions) {
$roleId = $roleModel->where('name', $role)->first()['id'];
foreach ($permissions as $permission) {
$permissionId = $permissionModel->where('name', $permission)->first()['id'];
if (!$rolePermissionModel->where('role_id', $roleId)->where('permission_id', $permissionId)->first()) {
$rolePermissionModel->save(['role_id' => $roleId, 'permission_id' => $permissionId]);
}
}
}
echo "Roles and permissions have been initialized.";
}
}
?>
@@ -0,0 +1,283 @@
<?php
namespace App\Controllers;
use App\Controllers\ClassProgressController;
use App\Models\ClassProgressAttachmentModel;
use App\Models\ClassProgressReportModel;
use App\Models\EnrollmentModel;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Exceptions\PageNotFoundException;
use Config\Database;
class ParentProgressController extends BaseController
{
protected ClassProgressReportModel $reportModel;
protected ClassProgressAttachmentModel $attachmentModel;
protected EnrollmentModel $enrollmentModel;
protected BaseConnection $db;
private ?array $parentSectionIds = null;
public function __construct()
{
helper(['url', 'form']);
$this->reportModel = new ClassProgressReportModel();
$this->attachmentModel = new ClassProgressAttachmentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->db = Database::connect();
}
public function index()
{
$sectionIds = $this->getParentSectionIds();
$sectionOptions = $this->buildSectionOptions($sectionIds);
$subjectSections = ClassProgressController::SUBJECT_SECTIONS;
$selectedSectionId = (int) $this->request->getGet('class_section_id');
$validSectionIds = array_keys($sectionOptions);
if ($selectedSectionId === 0 && ! empty($validSectionIds)) {
$selectedSectionId = $validSectionIds[0];
}
if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) {
$selectedSectionId = $validSectionIds[0] ?? null;
}
$rows = [];
if (! empty($sectionIds)) {
$builder = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
->whereIn('class_progress_reports.class_section_id', $sectionIds);
if ($selectedSectionId) {
$builder->where('class_progress_reports.class_section_id', $selectedSectionId);
}
$rows = $builder
->orderBy('week_start', 'DESC')
->findAll();
}
$reportGroups = $this->groupReportsByWeek($rows);
return view('parent/class_progress_list', [
'reportGroups' => $reportGroups,
'subjectSections' => $subjectSections,
'classSectionOptions' => $sectionOptions,
'selectedSectionId' => $selectedSectionId,
'hasSections' => ! empty($sectionIds),
]);
}
public function view($id)
{
$row = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
->find((int) $id);
if (! $row || ! $this->isSectionAccessible($row['class_section_id'] ?? null)) {
throw new PageNotFoundException('Progress report not found.');
}
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$row['flags'] = $this->decodeFlags($row['flags_json']);
$weeklyReports = $this->reportModel
->select('class_progress_reports.*')
->where('class_section_id', $row['class_section_id'])
->where('week_start', $row['week_start'])
->orderBy('subject', 'ASC')
->findAll();
$attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id'));
foreach ($weeklyReports as &$report) {
$report['attachments'] = $attachmentMap[$report['id']] ?? [];
if (empty($report['attachments']) && ! empty($report['attachment_path'])) {
$report['attachments'][] = [
'id' => $report['id'],
'name' => basename((string) $report['attachment_path']),
'legacy' => true,
];
}
}
return view('parent/class_progress_view', [
'row' => $row,
'weeklyReports' => $weeklyReports,
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
]);
}
public function attachment($id)
{
$row = $this->reportModel->find((int) $id);
if (! $row || ! $this->isSectionAccessible($row['class_section_id'] ?? null) || empty($row['attachment_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentFile($row);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
return $this->response->download($file, null)->setFileName(basename($file));
}
public function attachmentFile($id)
{
$attachment = $this->attachmentModel->find((int) $id);
if (! $attachment || empty($attachment['file_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$report = $this->reportModel->find((int) ($attachment['report_id'] ?? 0));
if (! $report || ! $this->isSectionAccessible($report['class_section_id'] ?? null)) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentPath($attachment['file_path']);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
$downloadName = $attachment['original_name'] ?: basename($file);
return $this->response->download($file, null)->setFileName($downloadName);
}
protected function getParentSectionIds(): array
{
if ($this->parentSectionIds !== null) {
return $this->parentSectionIds;
}
$parentId = (int) session()->get('user_id');
if ($parentId === 0) {
$this->parentSectionIds = [];
return $this->parentSectionIds;
}
$rows = $this->enrollmentModel
->select('class_section_id')
->where('parent_id', $parentId)
->where('is_withdrawn', 0)
->groupBy('class_section_id')
->findAll();
$ids = [];
foreach ($rows as $row) {
$classSectionId = (int) ($row['class_section_id'] ?? 0);
if ($classSectionId > 0) {
$ids[] = $classSectionId;
}
}
$this->parentSectionIds = array_values(array_unique($ids));
return $this->parentSectionIds;
}
protected function buildSectionOptions(array $sectionIds): array
{
if (empty($sectionIds)) {
return [];
}
$rows = $this->db->table('classSection')
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionIds)
->orderBy('class_section_name', 'ASC')
->get()
->getResultArray();
$options = [];
foreach ($rows as $row) {
$options[(int) ($row['class_section_id'] ?? 0)] = $row['class_section_name'] ?? 'Unknown section';
}
return $options;
}
protected function groupReportsByWeek(array $rows): array
{
$reportGroups = [];
foreach ($rows as $row) {
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$key = $row['week_start'] ?? '';
if ($key === '') {
continue;
}
if (! isset($reportGroups[$key])) {
$reportGroups[$key] = [
'week_start' => $row['week_start'] ?? '',
'week_end' => $row['week_end'] ?? '',
'class_section_name' => $row['class_section_name'] ?? '',
'reports' => [],
];
}
$reportGroups[$key]['reports'][$row['subject']] = $row;
}
return $reportGroups;
}
protected function isSectionAccessible(?int $classSectionId): bool
{
if (! $classSectionId) {
return false;
}
return in_array((int) $classSectionId, $this->getParentSectionIds(), true);
}
protected function resolveAttachmentFile(array $row): ?string
{
$path = trim((string) ($row['attachment_path'] ?? ''));
if ($path === '') {
return null;
}
return $this->resolveAttachmentPath($path);
}
protected function resolveAttachmentPath(string $path): ?string
{
$relative = preg_replace('#^writable/uploads/#', '', $path);
$absolute = WRITEPATH . 'uploads/' . ltrim($relative, '/');
return is_file($absolute) ? $absolute : null;
}
protected function loadAttachmentsForReports(array $reportIds): array
{
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
if (empty($reportIds)) {
return [];
}
$rows = $this->attachmentModel
->whereIn('report_id', $reportIds)
->orderBy('id', 'ASC')
->findAll();
$map = [];
foreach ($rows as $row) {
$reportId = (int) ($row['report_id'] ?? 0);
if ($reportId === 0) {
continue;
}
$map[$reportId][] = [
'id' => (int) $row['id'],
'name' => $row['original_name'] ?: basename((string) $row['file_path']),
];
}
return $map;
}
protected function decodeFlags(?string $json): array
{
if (! $json) {
return [];
}
$flags = json_decode($json, true);
return is_array($flags) ? $flags : [];
}
}
+571
View File
@@ -0,0 +1,571 @@
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Models\PrintRequestModel;
use App\Models\UserModel;
use App\Models\ClassModel;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\TeacherClassModel;
use App\Models\AdminNotificationSubjectModel;
use App\Models\NotificationModel;
use App\Models\UserNotificationModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class PrintRequests extends BaseController
{
protected $printRequestModel;
protected $userModel;
protected $classModel;
protected $teacherClassModel;
protected $configModel;
protected $classSectionModel;
protected $adminNotificationSubjectModel;
protected $notificationModel;
protected $userNotificationModel;
// Define the upload path as a constant or property for easy maintenance
protected $uploadPath = WRITEPATH . 'uploads/print_requests/';
public function __construct()
{
$this->printRequestModel = new PrintRequestModel();
$this->userModel = new UserModel();
$this->classModel = new ClassModel();
$this->teacherClassModel = new TeacherClassModel();
$this->configModel = new ConfigurationModel();
$this->classSectionModel = new ClassSectionModel();
$this->adminNotificationSubjectModel = new AdminNotificationSubjectModel();
$this->notificationModel = new NotificationModel();
$this->userNotificationModel = new UserNotificationModel();
helper(['form', 'url']);
}
public function teacher_index()
{
$teacher_id = session()->get('user_id');
$data['print_requests'] = $this->printRequestModel
->select('print_requests.*, admins.firstname as admin_firstname, admins.lastname as admin_lastname')
->join('users as admins', 'admins.id = print_requests.admin_id', 'left')
->where('print_requests.teacher_id', $teacher_id)
->findAll();
$teacher_classes = $this->teacherClassModel->getClassByTeacherId($teacher_id);
$data['class_id'] = !empty($teacher_classes) ? $teacher_classes[0]['class_section_id'] : null;
$dateOptions = $this->buildRequiredByOptions();
$data['sundays'] = $dateOptions['sundays'];
$data['times'] = $dateOptions['times'];
return view('print_requests/teacher_index', $data);
}
public function admin_index()
{
$db = \Config\Database::connect();
$query = $db->query("
SELECT
pr.*,
u.firstname,
u.lastname,
cs.class_section_name,
admins.firstname AS admin_firstname,
admins.lastname AS admin_lastname
FROM print_requests pr
LEFT JOIN users u ON u.id = pr.teacher_id
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id
LEFT JOIN users admins ON admins.id = pr.admin_id
ORDER BY
CASE
WHEN pr.status = 'not_assigned' THEN 1
WHEN pr.status = 'assigned' THEN 2
WHEN pr.status = 'done' THEN 3
WHEN pr.status = 'delivered' THEN 4
ELSE 5
END ASC,
pr.required_by ASC
");
$data['print_requests'] = $query->getResultArray();
return view('print_requests/admin_index', $data);
}
public function create()
{
$validationRules = [
'file' => 'uploaded[file]|max_size[file,5120]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]',
'page_selection' => 'permit_empty|regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]',
'num_copies' => 'required|integer|greater_than[0]',
'required_by' => 'required|valid_date',
'pickup_method' => 'required'
];
if (!$this->validate($validationRules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$teacher_id = session()->get('user_id');
$file = $this->request->getFile('file');
$newName = '';
if ($file->isValid() && !$file->hasMoved()) {
$newName = $file->getRandomName();
// Moved to WRITEPATH
$file->move($this->uploadPath, $newName);
}
$data = [
'teacher_id' => $teacher_id,
'class_id' => $this->request->getPost('class_id'),
'file_path' => $newName,
'page_selection' => trim((string) $this->request->getPost('page_selection')),
'num_copies' => $this->request->getPost('num_copies'),
'required_by' => $this->request->getPost('required_by'),
'pickup_method' => $this->request->getPost('pickup_method'),
'status' => 'not_assigned',
];
$printRequestId = (int) $this->printRequestModel->insert($data, true);
if ($printRequestId > 0) {
$this->notifyAdminsForPrintRequest($printRequestId, $data, 'created');
}
return redirect()->to('teacher/print-requests')->with('success', 'Print request created successfully.');
}
public function update($id)
{
$request = $this->printRequestModel->find($id);
if (!$request) {
return redirect()->back()->with('error', 'Print request not found.');
}
// Case 1: Admin status update
if ($this->request->getPost('status')) {
$user_id = session()->get('user_id');
$current_status = $request['status'];
$new_status = $this->request->getPost('status');
$allowed_transitions = [
'not_assigned' => ['assigned'],
'assigned' => ['not_assigned', 'done'],
'done' => ['delivered'],
'delivered' => []
];
if (!isset($allowed_transitions[$current_status]) || !in_array($new_status, $allowed_transitions[$current_status])) {
if ($current_status == $new_status) {
return redirect()->to('admin/print-requests');
}
return redirect()->to('admin/print-requests')->with('error', 'Invalid status transition.');
}
$data = ['status' => $new_status];
if ($new_status == 'assigned') {
$data['admin_id'] = $user_id;
} elseif ($new_status == 'not_assigned') {
$data['admin_id'] = null;
}
$this->printRequestModel->update($id, $data);
return redirect()->to('admin/print-requests')->with('success', 'Status updated successfully.');
}
// Case 2: Teacher edit
if ($this->request->getPost('num_copies')) {
$user_id = session()->get('user_id');
if ($request['teacher_id'] != $user_id) {
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to edit this request.');
}
if (!in_array($request['status'], ['not_assigned', 'assigned'])) {
return redirect()->to('teacher/print-requests')->with('error', 'Cannot edit a request that is already being processed.');
}
$validationRules = [
'num_copies' => 'required|integer|greater_than[0]',
'required_by' => 'required|valid_date',
'pickup_method' => 'required|in_list[Self Pickup,Delivered to class]'
];
$pageSelectionRule = 'regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]';
$validationRules['page_selection'] = 'permit_empty|' . $pageSelectionRule;
$hasNewFile = $this->request->getFile('file') && $this->request->getFile('file')->isValid();
if ($hasNewFile) {
$validationRules['file'] = 'uploaded[file]|max_size[file,5120]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]';
}
if (!$this->validate($validationRules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$data = [
'num_copies' => $this->request->getPost('num_copies'),
'required_by' => $this->request->getPost('required_by'),
'pickup_method' => $this->request->getPost('pickup_method'),
'page_selection' => trim((string) $this->request->getPost('page_selection')),
];
$file = $this->request->getFile('file');
if ($file && $file->isValid() && !$file->hasMoved()) {
// Remove old file from WRITEPATH
if ($request['file_path'] && file_exists($this->uploadPath . $request['file_path'])) {
@unlink($this->uploadPath . $request['file_path']);
}
$newName = $file->getRandomName();
$file->move($this->uploadPath, $newName);
$data['file_path'] = $newName;
}
$this->printRequestModel->update($id, $data);
$notifyPayload = array_merge($request, $data, ['id' => $id]);
$this->notifyAdminsForPrintRequest($id, $notifyPayload, 'updated');
return redirect()->to('teacher/print-requests')->with('success', 'Print request updated successfully.');
}
return redirect()->back()->with('error', 'Invalid request data.');
}
public function delete($id)
{
$teacher_id = session()->get('user_id');
$request = $this->printRequestModel->find($id);
if (!$request) {
return redirect()->to('teacher/print-requests')->with('error', 'Print request not found.');
}
if ($request['teacher_id'] != $teacher_id) {
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to delete this request.');
}
if (!in_array($request['status'], ['not_assigned', 'assigned'])) {
return redirect()->to('teacher/print-requests')->with('error', 'Cannot delete a request that is already being processed.');
}
$this->notifyAdminsForPrintRequest($id, $request, 'deleted');
// Delete file from WRITEPATH
if ($request['file_path'] && file_exists($this->uploadPath . $request['file_path'])) {
@unlink($this->uploadPath . $request['file_path']);
}
$this->printRequestModel->delete($id);
return redirect()->to('teacher/print-requests')->with('success', 'Print request deleted successfully.');
}
public function copy($id)
{
$teacher_id = session()->get('user_id');
$request = $this->printRequestModel->find($id);
if (!$request) {
return redirect()->to('teacher/print-requests')->with('error', 'Print request not found.');
}
if ($request['teacher_id'] != $teacher_id) {
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to copy this request.');
}
$filePath = trim((string) ($request['file_path'] ?? ''));
$candidates = [
$this->uploadPath . $filePath,
FCPATH . 'uploads/print_requests/' . $filePath,
];
$fileExists = false;
foreach ($candidates as $candidate) {
if ($filePath !== '' && file_exists($candidate)) {
$fileExists = true;
break;
}
}
if (!$fileExists) {
return redirect()->to('teacher/print-requests')->with('error', 'The original file is unavailable for copying.');
}
$data = [
'teacher_id' => $teacher_id,
'class_id' => $request['class_id'],
'file_path' => $filePath,
'page_selection' => $request['page_selection'] ?? null,
'num_copies' => $request['num_copies'],
'required_by' => $request['required_by'],
'pickup_method' => $request['pickup_method'],
'status' => 'not_assigned',
];
$copiedId = (int) $this->printRequestModel->insert($data, true);
if ($copiedId > 0) {
$this->notifyAdminsForPrintRequest($copiedId, $data, 'created');
}
return redirect()->to('teacher/print-requests')->with('success', 'Print request copied successfully.');
}
public function createCopy()
{
$validationRules = [
'num_copies' => 'required|integer|greater_than[0]',
'required_by' => 'required|valid_date',
'pickup_method' => 'required'
];
if (!$this->validate($validationRules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$teacher_id = session()->get('user_id');
$data = [
'teacher_id' => $teacher_id,
'class_id' => $this->request->getPost('class_id'),
'file_path' => '',
'page_selection' => null,
'num_copies' => $this->request->getPost('num_copies'),
'required_by' => $this->request->getPost('required_by'),
'pickup_method' => $this->request->getPost('pickup_method'),
'status' => 'not_assigned',
];
$printRequestId = (int) $this->printRequestModel->insert($data, true);
if ($printRequestId > 0) {
$this->notifyAdminsForPrintRequest($printRequestId, $data, 'created');
}
return redirect()->to('teacher/print-requests')->with('success', 'Copy request submitted. Please hand the original document to the copy center.');
}
private function buildRequiredByOptions(): array
{
$tz = new \DateTimeZone('America/Chicago');
$currentDate = new \DateTime('now', $tz);
$currentYear = (int)$currentDate->format('Y');
$endYear = ($currentDate->format('n') > 6) ? $currentYear + 1 : $currentYear;
$endDate = new \DateTime("first Sunday of June {$endYear}", $tz);
$endDate->modify('+1 week');
$sundays = [];
$date = new \DateTime('now', $tz);
$date->setTime(0, 0, 0);
if ($date->format('w') !== '0') {
$date->modify('next Sunday');
}
while ($date < $endDate) {
$sundays[] = $date->format('Y-m-d');
$date->modify('+1 week');
}
$times = [];
$start = new \DateTime('10:00', $tz);
$end = new \DateTime('13:00', $tz);
$interval = new \DateInterval('PT15M');
$period = new \DatePeriod($start, $interval, $end);
foreach ($period as $time) {
$times[] = $time->format('H:i');
}
return [
'sundays' => $sundays,
'times' => $times,
];
}
public function serveFile(string $filename, string $mode = 'inline')
{
$safeName = basename(trim($filename));
if ($safeName === '') {
throw new PageNotFoundException('File not specified.');
}
$candidates = [
WRITEPATH . 'uploads/print_requests/' . $safeName,
FCPATH . 'uploads/print_requests/' . $safeName,
];
$path = null;
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$path = $candidate;
break;
}
}
if (!$path) {
throw new PageNotFoundException('File not found.');
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
$disposition = strtolower(trim($mode)) === 'download' ? 'attachment' : 'inline';
return $this->response
->download($path, null)
->setFileName($safeName)
->setHeader('Content-Type', $mime)
->setHeader('Content-Disposition', sprintf('%s; filename="%s"', $disposition, $safeName));
}
private function notifyAdminsForPrintRequest(int $printRequestId, array $requestData, string $event = 'created'): void
{
try {
$db = \Config\Database::connect();
} catch (\Throwable $e) {
return;
}
if (
!$db->tableExists('admin_notification_subjects') ||
!$db->tableExists('notifications') ||
!$db->tableExists('user_notifications')
) {
log_message('error', 'Print request notifications skipped: required tables missing.');
return;
}
$rows = $this->adminNotificationSubjectModel
->select('admin_id')
->where('subject', 'print_requests')
->findAll();
$adminIds = array_values(array_unique(array_map('intval', array_column($rows, 'admin_id'))));
if (empty($adminIds)) {
log_message('info', 'Print request notifications skipped: no admins subscribed to print_requests.');
return;
}
$teacherId = (int) ($requestData['teacher_id'] ?? 0);
$teacherName = '';
if ($teacherId > 0) {
$teacher = $this->userModel->find($teacherId);
$teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
}
if ($teacherName === '') {
$teacherName = $teacherId > 0 ? ('Teacher #' . $teacherId) : 'Teacher';
}
$className = '';
$classId = $requestData['class_id'] ?? null;
if ($classId !== null && $classId !== '') {
$className = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classId) ?? '');
}
$event = strtolower(trim($event));
$eventMap = [
'created' => [
'title' => 'New Print Request',
'intro' => 'A new print request has been submitted.',
'lead' => 'New print request from ',
],
'updated' => [
'title' => 'Print Request Updated',
'intro' => 'A print request has been updated.',
'lead' => 'Updated print request from ',
],
'deleted' => [
'title' => 'Print Request Removed',
'intro' => 'A print request has been deleted.',
'lead' => 'Print request removed for ',
],
];
$eventConfig = $eventMap[$event] ?? $eventMap['created'];
$filePath = trim((string) ($requestData['file_path'] ?? ''));
$isCopyRequest = $filePath === '';
$messageParts = [$eventConfig['lead'] . $teacherName];
if ($className !== '') {
$messageParts[] = 'Class: ' . $className;
}
if (!empty($requestData['num_copies'])) {
$messageParts[] = 'Copies: ' . (int) $requestData['num_copies'];
}
if (!empty($requestData['required_by'])) {
$messageParts[] = 'Needed by: ' . $requestData['required_by'];
}
if (!empty($requestData['page_selection'])) {
$messageParts[] = 'Pages: ' . $requestData['page_selection'];
}
if (!empty($requestData['pickup_method'])) {
$messageParts[] = 'Pickup: ' . $requestData['pickup_method'];
}
$message = implode(' | ', $messageParts);
$actionUrl = site_url('admin/print-requests');
$payload = [
'title' => $eventConfig['title'],
'message' => $message,
'target_group' => 'admin',
'delivery_channels' => 'in_app,email',
'priority' => 'normal',
'status' => 'pending',
'action_url' => $isCopyRequest ? '' : $actionUrl,
'scheduled_at' => utc_now(),
'school_year' => $this->configModel->getConfig('school_year'),
'semester' => $this->configModel->getConfig('semester'),
];
$notificationFields = $db->getFieldNames('notifications');
if (is_array($notificationFields) && !empty($notificationFields)) {
$payload = array_intersect_key($payload, array_flip($notificationFields));
}
$notificationId = (int) $this->notificationModel->insert($payload, true);
if ($notificationId <= 0) {
return;
}
$userNotificationFields = $db->getFieldNames('user_notifications');
$userFieldMap = is_array($userNotificationFields) ? array_flip($userNotificationFields) : [];
$batch = [];
foreach ($adminIds as $adminId) {
if ($adminId <= 0) {
continue;
}
$row = [
'notification_id' => $notificationId,
'user_id' => $adminId,
'is_read' => 0,
'delivered' => 0,
];
if (!empty($userFieldMap)) {
$row = array_intersect_key($row, $userFieldMap);
}
$batch[] = $row;
}
if (!empty($batch)) {
$this->userNotificationModel->insertBatch($batch);
}
$adminRows = $this->userModel
->select('id, firstname, lastname, email')
->whereIn('id', $adminIds)
->findAll();
if (empty($adminRows)) {
return;
}
$mailer = new \App\Controllers\View\EmailController();
$subject = $eventConfig['title'];
$body = '<p>' . esc($eventConfig['intro']) . '</p>'
. '<p><strong>From:</strong> ' . esc($teacherName) . '</p>'
. ($className !== '' ? '<p><strong>Class:</strong> ' . esc($className) . '</p>' : '')
. (!empty($requestData['num_copies']) ? '<p><strong>Copies:</strong> ' . (int) $requestData['num_copies'] . '</p>' : '')
. (!empty($requestData['required_by']) ? '<p><strong>Needed by:</strong> ' . esc($requestData['required_by']) . '</p>' : '')
. (!empty($requestData['page_selection']) ? '<p><strong>Pages:</strong> ' . esc($requestData['page_selection']) . '</p>' : '')
. (!empty($requestData['pickup_method']) ? '<p><strong>Pickup Method:</strong> ' . esc($requestData['pickup_method']) . '</p>' : '')
. ($isCopyRequest ? '' : '<p><a href="' . esc($actionUrl) . '">View print requests</a></p>');
foreach ($adminRows as $adminRow) {
$email = trim((string) ($adminRow['email'] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$mailer->sendEmail($email, $subject, $body, 'notifications');
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Controllers;
use CodeIgniter\RESTful\ResourceController;
class ProofreadController extends ResourceController
{
public function check()
{
// Basic per-IP throttling: 10 requests per minute
$throttler = service('throttler');
$key = 'proofread-' . $this->request->getIPAddress();
if (!$throttler->check($key, 10, MINUTE)) {
return $this->respond([
'ok' => false,
'error' => 'Too many requests. Try again in a minute.',
'csrfHash' => csrf_hash(),
], 429);
}
// Accept form-urlencoded payload to play nicely with CSRF protection
$text = (string) ($this->request->getPost('text') ?? '');
if ($text === '' || mb_strlen($text) > 20000) {
return $this->respond([
'ok' => false,
'error' => 'Invalid text (empty or too long).',
'csrfHash' => csrf_hash(),
], 422);
}
$client = \Config\Services::curlrequest(['timeout' => 10]);
try {
$resp = $client->post('https://api.languagetool.org/v2/check', [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'form_params' => [
'text' => $text,
'language' => 'en-US',
],
]);
return $this->respond([
'ok' => true,
'result' => json_decode($resp->getBody(), true),
'csrfHash' => csrf_hash(),
]);
} catch (\Throwable $e) {
return $this->respond([
'ok' => false,
'error' => 'Proofread service unavailable.',
'csrfHash' => csrf_hash(),
], 502);
}
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Libraries\StaffTimeOffLinkService;
class TimeOffNotificationController extends BaseController
{
/**
* Triggered when the principal clicks the confirmation link in the email.
* Sends a courtesy notification to the staff member who submitted the request.
*/
public function notify(string $token = '')
{
$service = new StaffTimeOffLinkService();
$payload = $service->parseToken($token);
if (!$payload) {
return $this->respondHtml('Sorry, this link is invalid or has expired.', 400);
}
$email = trim((string)($payload['email'] ?? ''));
$fullName = trim((string)($payload['name'] ?? ''));
if ($email === '') {
return $this->respondHtml('Unable to notify the requester because their email was not included.', 400);
}
$dates = (string)($payload['dates'] ?? '-');
$role = (string)($payload['role'] ?? 'staff');
$reason = (string)($payload['reason'] ?? '');
$reasonType = (string)($payload['reason_type'] ?? '');
$submittedAt = (string)($payload['submitted_at'] ?? '');
$origin = (string)($payload['origin'] ?? 'staff portal');
try {
$mailer = \Config\Services::emailService();
$subject = 'TimeOff Request - Principal Acknowledgment';
$body = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
. '<p>Dear ' . esc($fullName ?: 'Staff Member') . ',</p>'
. '<p>This is a courtesy confirmation that your time-off request submitted via the '
. esc($origin) . ' has been reviewed by the principal.</p>'
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
. '<tr><td><strong>Role</strong></td><td>' . esc($role) . '</td></tr>'
. '<tr><td><strong>Dates</strong></td><td>' . esc($dates ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason Type</strong></td><td>' . esc($reasonType ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason</strong></td><td>' . esc($reason ?: '-') . '</td></tr>'
. ($submittedAt !== '' ? '<tr><td><strong>Submitted At</strong></td><td>' . esc($submittedAt) . '</td></tr>' : '')
. '</table>'
. '<p>If you have any follow-up questions, please contact the principal\'s office directly.</p>'
. '<p style="margin-top:16px;">Thank you,<br>Al Rahma School Administration</p>'
. '</div>';
$mailer->send($email, $subject, $body, 'notifications');
} catch (\Throwable $e) {
log_message('error', 'Failed to send TimeOff confirmation to requester: ' . $e->getMessage());
return $this->respondHtml('Something went wrong while sending the confirmation email. Please contact IT for help.', 500);
}
return $this->respondHtml('Confirmation email sent to the requester. You may close this window.');
}
private function respondHtml(string $message, int $statusCode = 200)
{
$html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>TimeOff</title></head>'
. '<body style="font-family:Arial,Helvetica,sans-serif;padding:24px;">'
. '<p>' . esc($message) . '</p>'
. '</body></html>';
return $this->response
->setStatusCode($statusCode)
->setHeader('Content-Type', 'text/html; charset=UTF-8')
->setBody($html);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\StudentClassModel;
use App\Models\UserModel;
use App\Models\TeacherClassModel;
use App\Models\StudentModel;
use App\Models\TeacherModel;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use Config\Database;
class AssignmentController extends BaseController
{
protected $userModel;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $studentModel;
protected $teacherClassModel;
protected $studentClassModel;
protected $classSectionModel;
public function __construct()
{
helper('auth');
// Load models
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->studentModel = new StudentModel();
$this->classSectionModel = new ClassSectionModel();
$this->teacherClassModel = new TeacherClassModel();
$this->studentClassModel = new StudentClassModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function index()
{
$data = [
'classSections' => []
];
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
$tcQ = $this->teacherClassModel;
if ($year !== '') {
$tcQ = $tcQ->where('school_year', $year);
}
$teacherClassesAll = $tcQ->findAll();
$scQ = $this->studentClassModel->active();
if ($year !== '') {
$scQ = $scQ->where('student_class.school_year', $year);
}
$studentClassesAll = $scQ->findAll();
// Group teacher and student classes by section
$teacherBySection = [];
foreach ($teacherClassesAll as $tc) {
$teacherBySection[$tc['class_section_id']][] = $tc;
}
$studentsBySection = [];
foreach ($studentClassesAll as $sc) {
$studentsBySection[$sc['class_section_id']][] = $sc;
}
$allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)));
foreach ($allSectionIds as $classSectionId) {
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
$studentClasses = $studentsBySection[$classSectionId] ?? [];
$hasTeacher = !empty($teacherClasses);
$hasStudents = !empty($studentClasses);
if (!$hasTeacher && !$hasStudents) {
continue;
}
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
$mainTeachers = [];
$teacherAssistants = [];
$sectionSemester = '';
$sectionSchoolYear = '';
$description = '';
if ($hasTeacher) {
foreach ($teacherClasses as $teacherClass) {
$teacher = $this->userModel->find($teacherClass['teacher_id']);
if (!$teacher) continue;
$teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
if ($teacherName === '') continue;
if (($teacherClass['position'] ?? '') === 'main') {
$mainTeachers[] = $teacherName;
} elseif (($teacherClass['position'] ?? '') === 'ta') {
$teacherAssistants[] = $teacherName;
}
if ($sectionSemester === '' && !empty($teacherClass['semester'])) {
$sectionSemester = (string)$teacherClass['semester'];
}
if ($sectionSchoolYear === '' && !empty($teacherClass['school_year'])) {
$sectionSchoolYear = (string)$teacherClass['school_year'];
}
if ($description === '' && !empty($teacherClass['description'])) {
$description = (string)$teacherClass['description'];
}
}
}
$students = [];
foreach ($studentClasses as $studentClass) {
if ($sectionSemester === '' && !empty($studentClass['semester'])) {
$sectionSemester = (string)$studentClass['semester'];
}
if ($sectionSchoolYear === '' && !empty($studentClass['school_year'])) {
$sectionSchoolYear = (string)$studentClass['school_year'];
}
if ($description === '' && !empty($studentClass['description'])) {
$description = (string)$studentClass['description'];
}
$student = $this->studentModel
->where('id', $studentClass['student_id'])
->where('is_active', 1)
->first();
if (!$student) {
continue;
}
$students[] = [
'id' => (int)$student['id'],
'firstname' => esc($student['firstname']),
'lastname' => esc($student['lastname']),
'age' => esc($student['age']),
'gender' => esc($student['gender']),
'registration_grade' => esc($student['registration_grade']),
'photo_consent' => esc($student['photo_consent'] ? 'Yes' : 'No'),
'tuition_paid' => esc($student['tuition_paid'] ? 'Yes' : 'No'),
'school_id' => esc($student['school_id']),
];
}
$sectionSemesterDisplay = $sectionSemester !== '' ? $sectionSemester : ((string)($this->semester ?? ''));
$sectionSchoolYearDisplay = $sectionSchoolYear !== '' ? $sectionSchoolYear : ((string)($this->schoolYear ?? ''));
$data['classSections'][] = [
'class_section_id' => $classSectionId,
'class_section_name' => $classSectionName,
'main_teachers' => $mainTeachers,
'teacher_assistants' => $teacherAssistants,
'students' => $students,
'semester' => $sectionSemesterDisplay,
'school_year' => $sectionSchoolYearDisplay,
'description' => $description,
];
}
$schoolYearsList = [];
try {
$db = Database::connect();
$yearsQuery = $db->table('teacher_class')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($yearsQuery as $row) {
$val = (string)($row['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYearsList, true)) {
$schoolYearsList[] = $val;
}
}
} catch (\Throwable $e) {
// ignore fallback below
}
if (empty($schoolYearsList) && $this->schoolYear !== null && $this->schoolYear !== '') {
$schoolYearsList[] = (string)$this->schoolYear;
}
// Sort sections
usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name']));
$data['schoolYears'] = $schoolYearsList;
$data['schoolYear'] = $year;
$data['selectedYear'] = $year;
$data['selectedSemester'] = $selectedSemester;
return view('administrator/class_assignment', $data);
}
public function save()
{
$data = [
'student_id' => $this->request->getPost('student_id'),
'class_section_id' => $this->request->getPost('class_section_id'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
'description' => $this->request->getPost('description'),
'updated_by' => session()->get('user_id'),
];
$this->studentClassModel->save($data);
return redirect()->to('/assignments')->with('message', 'Assignment saved successfully');
}
// API: JSON payload for Classes List page
public function classAssignmentData()
{
$teacherClassesAll = $this->teacherClassModel->findAll();
$studentClassesAll = $this->studentClassModel->findAll();
// Group by section
$teacherBySection = [];
foreach ($teacherClassesAll as $tc) {
$secId = (int)($tc['class_section_id'] ?? 0);
if ($secId) $teacherBySection[$secId][] = $tc;
}
$studentsBySection = [];
foreach ($studentClassesAll as $sc) {
$secId = (int)($sc['class_section_id'] ?? 0);
if ($secId) $studentsBySection[$secId][] = $sc;
}
$allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))));
$classSections = [];
foreach ($allSectionIds as $classSectionId) {
$hasTeacher = !empty($teacherBySection[$classSectionId]);
$hasStudents = !empty($studentsBySection[$classSectionId]);
if (!$hasTeacher && !$hasStudents) continue;
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
$mainTeachers = [];
$teacherAssistants = [];
$semesterMeta = '';
$schoolYearMeta = '';
$descriptionMeta = '';
if ($hasTeacher) {
foreach ($teacherBySection[$classSectionId] as $teacherClass) {
$teacher = $this->userModel->find((int)$teacherClass['teacher_id']);
if ($teacher) {
$tname = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
if (($teacherClass['position'] ?? '') === 'main') {
$mainTeachers[] = $tname;
} elseif (($teacherClass['position'] ?? '') === 'ta') {
$teacherAssistants[] = $tname;
}
}
// assign meta (same for all rows in a section)
if ($semesterMeta === '' && !empty($teacherClass['semester'])) {
$semesterMeta = (string)$teacherClass['semester'];
}
if ($schoolYearMeta === '' && !empty($teacherClass['school_year'])) {
$schoolYearMeta = (string)$teacherClass['school_year'];
}
if ($descriptionMeta === '' && !empty($teacherClass['description'])) {
$descriptionMeta = (string)$teacherClass['description'];
}
}
}
// Load students for the section
$students = [];
foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) {
$stu = $this->studentModel
->where('id', (int)$studentClass['student_id'])
->where('is_active', 1)
->first();
if (!$stu) continue;
$students[] = [
'id' => (int)$stu['id'],
'firstname' => (string)($stu['firstname'] ?? ''),
'lastname' => (string)($stu['lastname'] ?? ''),
'age' => $stu['age'] ?? null,
'gender' => (string)($stu['gender'] ?? ''),
'registration_grade' => (string)($stu['registration_grade'] ?? ''),
'photo_consent' => (bool)($stu['photo_consent'] ?? false),
'tuition_paid' => (bool)($stu['tuition_paid'] ?? false),
'school_id' => (string)($stu['school_id'] ?? ''),
];
}
$classSections[] = [
'class_section_id' => (int)$classSectionId,
'class_section_name' => $classSectionName,
'main_teachers' => array_values(array_unique($mainTeachers)),
'teacher_assistants' => array_values(array_unique($teacherAssistants)),
'students' => $students,
'semester' => $semesterMeta ?: (string)$this->semester,
'school_year' => $schoolYearMeta ?: (string)$this->schoolYear,
'description' => $descriptionMeta,
];
}
// Sort by class_section_name
usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
return $this->response->setJSON([
'classSections' => $classSections,
'csrfHash' => csrf_hash(),
'semester' => (string)$this->semester,
'school_year' => (string)$this->schoolYear,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AttendanceCommentTemplateModel;
class AttendanceCommentTemplateController extends BaseController
{
protected $templateModel;
public function __construct()
{
$this->templateModel = new AttendanceCommentTemplateModel();
}
// Method to load configuration management page
public function index()
{
helper('url');
return view('attendance_templates/index', [
'templateEndpoint' => site_url('api/attendance-templates'),
]);
}
public function save()
{
$id = $this->request->getPost('id');
$data = [
'min_score' => $this->request->getPost('min_score'),
'max_score' => $this->request->getPost('max_score'),
'template_text' => $this->request->getPost('template_text'),
'is_active' => $this->request->getPost('is_active') === 'on' ? 1 : 0,
];
if ($id) {
$this->templateModel->update($id, $data);
} else {
$this->templateModel->insert($data);
}
return $this->response->setJSON(['status' => 'success']);
}
public function delete()
{
$id = $this->request->getPost('id');
if ($id) {
$this->templateModel->delete($id);
return $this->response->setJSON(['status' => 'success']);
}
return $this->response->setJSON(['status' => 'error', 'message' => 'ID not provided']);
}
public function listData()
{
$rows = $this->templateModel
->orderBy('min_score', 'ASC')
->findAll();
$templates = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'min_score' => (int) ($row['min_score'] ?? 0),
'max_score' => (int) ($row['max_score'] ?? 0),
'template_text' => (string) ($row['template_text'] ?? ''),
'is_active' => (bool) ($row['is_active'] ?? false),
];
}, $rows ?? []);
return $this->response->setJSON(['templates' => $templates]);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\RESTful\ResourceController;
use App\Models\UserModel;
use App\Models\AuthorizedUserModel;
use CodeIgniter\I18n\Time;
class AuthorizedUsersController extends ResourceController
{
protected $userModel;
protected $authorizedUserModel;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
{
$this->userModel = new UserModel();
$this->authorizedUserModel = new AuthorizedUserModel();
}
/**
* Return a list of authorized users for the logged-in main user.
*
* @return ResponseInterface
*/
public function index()
{
$userId = session()->get('user_id');
$authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll();
return $this->respond($authorizedUsers);
}
/**
* Show a specific authorized user by ID.
*
* @param int|string|null $id
* @return ResponseInterface
*/
public function show($id = null)
{
$authorizedUser = $this->authorizedUserModel->find($id);
if (!$authorizedUser) {
return $this->failNotFound('Authorized user not found.');
}
return $this->respond($authorizedUser);
}
/**
* Create a new authorized user (add by email).
*
* @return ResponseInterface
*/
public function create()
{
$email = strtolower($this->request->getPost('email'));
// Validate email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->failValidationErrors('Invalid email address.');
}
$user = $this->userModel->where('email', $email)->first();
if (!$user) {
return $this->failNotFound('No user found with this email.');
}
// Generate a token for confirmation
helper('text');
$token = bin2hex(random_bytes(48));
// Add entry to the authorized_users table
$this->authorizedUserModel->insert([
'user_id' => session()->get('user_id'), // Main user ID
'authorized_user_id' => $user['id'],
'email' => $email,
'token' => $token,
'status' => 'Pending'
]);
// Send confirmation email to the authorized user
$this->sendAuthorizedUserConfirmationEmail($email, $token);
return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']);
}
/**
* Update an existing authorized user.
*
* @param int|string|null $id
* @return ResponseInterface
*/
public function update($id = null)
{
// Fetch the authorized user
$authorizedUser = $this->authorizedUserModel->find($id);
if (!$authorizedUser) {
return $this->failNotFound('Authorized user not found.');
}
// Update the authorized users information (e.g., email)
$email = strtolower($this->request->getPost('email'));
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$authorizedUser['email'] = $email;
}
$this->authorizedUserModel->save($authorizedUser);
return $this->respondUpdated(['message' => 'Authorized user information updated.']);
}
/**
* Delete an authorized user.
*
* @param int|string|null $id
* @return ResponseInterface
*/
public function delete($id = null)
{
$authorizedUser = $this->authorizedUserModel->find($id);
if (!$authorizedUser) {
return $this->failNotFound('Authorized user not found.');
}
// Delete the authorized user record
$this->authorizedUserModel->delete($id);
return $this->respondDeleted(['message' => 'Authorized user deleted successfully.']);
}
/**
* Confirms the authorized user's token and allows them to set a password.
*
* @return ResponseInterface
*/
public function confirm()
{
$token = $this->request->getGet('token');
if (!$token) {
return $this->fail('Invalid confirmation link.');
}
$authorizedUser = $this->authorizedUserModel->where('token', $token)->first();
if (!$authorizedUser) {
return $this->fail('Invalid or expired confirmation link.');
}
// Mark the authorized user as active
$this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]);
return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']);
}
/**
* Displays the form for the authorized user to set their password.
*
* @param int $authorizedUserId
* @return ResponseInterface
*/
public function setPassword($authorizedUserId)
{
$user = $this->userModel->find($authorizedUserId);
if (!$user) {
return $this->failNotFound('User not found.');
}
return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]);
}
/**
* Processes the password for the authorized user.
*
* @return ResponseInterface
*/
/*
public function savePassword()
{
// Validate the request
$validation = \Config\Services::validation();
$validation->setRules([
'password' => 'required|min_length[6]',
'password_confirm' => 'required|matches[password]',
'user_id' => 'required|integer'
]);
if (!$this->validate($validation->getRules())) {
return $this->failValidationErrors($validation->getErrors());
}
// Get the validated input
$userId = $this->request->getPost('user_id');
$password = $this->request->getPost('password');
$model = new UserModel();
$user = $model->find($userId);
if (!$user) {
return $this->failNotFound('User not found.');
}
// Save the password
$model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]);
return $this->respond(['message' => 'Password has been successfully set.']);
}
*/
/**
* Sends a confirmation email to the authorized user.
*
* @param string $email
* @param string $token
*/
private function sendAuthorizedUserConfirmationEmail($email, $token)
{
// Generate the confirmation link
$confirmLink = site_url('/confirm_authorized_user?token=' . $token);
// Compose the email message
$message = "
<p>You have been added as an authorized user for another account. Click the link below to confirm your access:</p>
<p><a href='{$confirmLink}'>Confirm Access</a></p>
<p>If you did not request this, please ignore this email.</p>
";
// Create an instance of the EmailController
$emailController = new \App\Controllers\View\EmailController();
$subject = 'Authorized User Confirmation';
// Send email
if ($emailController->sendEmail($email, $subject, $message)) {
log_message('info', 'Authorized user confirmation email sent to ' . $email);
} else {
log_message('error', 'Failed to send authorized user confirmation email to ' . $email);
}
}
}
+850
View File
@@ -0,0 +1,850 @@
<?php
namespace App\Controllers\View;
class BadgesController extends PrintablesBaseController
{
//Badges
public function badge()
{
// 1) Collect & sanitize IDs
$userIds = $this->request->getPost('user_ids') ?? [];
if (!is_array($userIds)) $userIds = [$userIds];
$userIds = array_values(array_filter(array_map(static function ($v) {
$v = is_string($v) ? trim($v) : $v;
return ($v !== '' && $v !== null) ? (int)$v : null;
}, $userIds), static fn($v) => $v !== null));
$userIds = array_values(array_unique($userIds));
// 2) Consistent school_year
$schoolYear = $this->request->getPost('school_year')
?? $this->request->getGet('school_year')
?? ($this->schoolYear ?? null);
// 2a) Posted maps from the view
$rolesMap = $this->request->getPost('roles') ?? [];
$classesMap = $this->request->getPost('classes') ?? [];
if (!is_array($rolesMap)) $rolesMap = [];
if (!is_array($classesMap)) $classesMap = [];
// 2b) Normalizer to mirror the view's formatting
$formatRole = static function (?string $role): string {
$role = (string)$role;
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/', ' ', trim($role));
if ($role === '') return '';
$out = [];
foreach (explode(' ', $role) as $w) {
if ($w === '') continue;
if (preg_match('/^[A-Za-z]{1,3}$/', $w)) {
$out[] = strtoupper($w);
} else {
$out[] = ucfirst(strtolower($w));
}
}
return implode(' ', $out);
};
// 3) PDF setup
$pdf = new \FPDF('P', 'mm', 'A4');
$pdf->SetAutoPageBreak(false);
// Layout: 2 cols - 5 rows = 10 per page
$badgeW = 95;
$badgeH = 67;
$marginL = 14;
$marginT = 6;
$gutterX = 0;
$gutterY = 0;
$cols = 2;
$rows = 4;
$perPage = $cols * $rows;
$pagesAdded = 0;
$i = 0;
$norm = static function (?string $s): string {
$s = (string)$s;
$s = str_replace(["\xC2\xA0", "\xA0"], ' ', $s);
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
$s = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $s) ?? $s;
return trim($s);
};
$resolveRole = static function (array $info) use ($norm): string {
$candidates = [
'role',
'active_role',
'role_name',
function ($r) {
if (!empty($r['roles'])) {
$parts = array_filter(array_map('trim', explode(',', (string)$r['roles'])));
return $parts[0] ?? '';
}
return '';
},
'job_title',
'title',
'position',
'staff_role',
'department_role',
'dept_role'
];
foreach ($candidates as $key) {
if (is_callable($key)) {
$v = $key($info);
if ($norm($v) !== '') return $norm($v);
} else {
if (!empty($info[$key])) {
$v = $norm((string)$info[$key]);
if ($v !== '') return $v;
}
}
}
return 'STAFF';
};
$seen = [];
foreach ($userIds as $uid) {
$info = $this->getUserInfoById($uid, $schoolYear);
if (!$info || !is_array($info)) continue;
$userId = isset($info['user_id']) ? (int)$info['user_id']
: (isset($info['id']) ? (int)$info['id']
: (isset($info['users.id']) ? (int)$info['users.id'] : $uid));
$name = $norm($info['name'] ?? (($info['firstname'] ?? '') . ' ' . ($info['lastname'] ?? '')));
// Prefer posted formatted role; else fallback and format server-side
$postedRole = $rolesMap[$userId] ?? null;
$roleResolved = $postedRole !== null ? $postedRole : $resolveRole($info);
$roleResolved = $formatRole((string)$roleResolved);
$info['role_resolved'] = $roleResolved;
// Prefer posted class name if provided (keeps what the user saw)
if (!empty($classesMap[$userId])) {
$info['class_section_name'] = (string)$classesMap[$userId];
}
$class = $norm($info['class_section_name'] ?? '');
$badgeKey = strtolower(trim($userId . '|' . $name . '|' . $roleResolved . '|' . $class));
if (isset($seen[$badgeKey])) continue;
$seen[$badgeKey] = true;
if (($i % $perPage) === 0) {
$pdf->AddPage();
$pagesAdded++;
}
$indexOnPage = $i % $perPage;
$row = intdiv($indexOnPage, $cols);
$col = $indexOnPage % $cols;
$x = $marginL + $col * ($badgeW + $gutterX);
$y = $marginT + $row * ($badgeH + $gutterY);
// drawBadgeInCell expects 'role_resolved' and (optionally) 'class_section_name'
$this->drawBadgeInCell($pdf, $info, $x, $y, $badgeW, $badgeH);
$i++;
}
if ($pagesAdded === 0) {
$pdf->AddPage();
$pdf->SetFont('Arial', '', 10);
$pdf->SetXY(10, 20);
$pdf->MultiCell(0, 6, "No valid staff selected or data not found.", 0, 'L');
}
if (ob_get_length()) {
ob_end_clean();
}
$pdfString = $pdf->Output('S');
// 4) Track prints (best-effort; swallow errors if table missing)
try {
$actorId = (int) (session()->get('user_id') ?? 0) ?: null;
$this->badgePrintLogModel->logPrints(
$userIds,
$actorId,
is_string($schoolYear) ? $schoolYear : null,
is_array($rolesMap) ? $rolesMap : [],
is_array($classesMap) ? $classesMap : [],
1
);
} catch (\Throwable $e) {
log_message('error', 'Failed to log badge prints: ' . $e->getMessage());
}
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="Staff_Badges.pdf"')
->setBody($pdfString);
}
/**
* API: Return print status for given users (count + last print time).
* GET: user_ids[] or CSV in user_ids, optional school_year
* Response: { ok: true, data: { <id>: { count, last_printed_at, last_printed_by } } }
*/
public function badgePrintStatus()
{
$ids = $this->request->getGet('user_ids');
if (is_string($ids)) {
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
}
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
$ids = array_values(array_unique(array_map(static fn($v) => (int)$v, $ids)));
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
try {
$status = $this->badgePrintLogModel->getStatus($ids, is_string($schoolYear) ? $schoolYear : null);
} catch (\Throwable $e) {
return $this->response->setJSON(['ok' => false, 'error' => 'Failed to query status']);
}
return $this->response->setJSON([
'ok' => true,
'data' => $status,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
/**
* API: Log a set of prints explicitly (optional; PDF endpoint already logs).
* POST: user_ids[], optional school_year, roles[ID], classes[ID]
*/
public function logBadgePrint()
{
$ids = $this->request->getPost('user_ids') ?? [];
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
$ids = array_values(array_unique(array_map(static fn($v) => (int)$v, $ids)));
$roles = $this->request->getPost('roles') ?? [];
$class = $this->request->getPost('classes') ?? [];
if (!is_array($roles)) $roles = [];
if (!is_array($class)) $class = [];
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
$actorId = (int) (session()->get('user_id') ?? 0) ?: null;
try {
$cnt = $this->badgePrintLogModel->logPrints(
$ids,
$actorId,
is_string($schoolYear) ? $schoolYear : null,
$roles,
$class,
1
);
return $this->response->setJSON([
'ok' => true,
'inserted' => $cnt,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
} catch (\Throwable $e) {
return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
}
}
protected function drawBadgeInCell(\FPDF $pdf, array $data, float $x, float $y, float $w, float $h): void
{
// Background & border
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $w, $h, 'F');
$pdf->SetDrawColor(200, 200, 200);
$pdf->Rect($x, $y, $w, $h);
// Logos
$logoSize = 6;
if (!empty($data['school_logo']) && file_exists($data['school_logo'])) {
$pdf->Image($data['school_logo'], $x + $w - 6 - $logoSize, $y + 4, $logoSize + 3, $logoSize - 1);
}
if (!empty($data['isgl_logo']) && file_exists($data['isgl_logo'])) {
$pdf->Image($data['isgl_logo'], $x + 4, $y + 4, $logoSize + 3, $logoSize - 1);
}
// Helpers
$padX = 4;
$cursorY = $y + 4 + $logoSize + 2;
$norm = static function (?string $s): string {
$s = (string)$s;
$s = str_replace(["\xC2\xA0", "\xA0"], ' ', $s);
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
$s = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $s) ?? $s;
return trim($s);
};
$toPdf = static function (string $s): string {
if (function_exists('iconv')) {
$o = @iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $s);
if ($o !== false && $o !== '') return $o;
}
if (function_exists('mb_convert_encoding')) {
$o = @mb_convert_encoding($s, 'ISO-8859-1', 'UTF-8');
if ($o !== '') return $o;
}
$o = @utf8_decode($s);
return $o !== '' ? $o : 'STAFF';
};
$fitText = static function (\FPDF $pdf, string $text, int $startSize, int $minSize, float $maxWidth): int {
$size = $startSize;
while ($size >= $minSize) {
$pdf->SetFontSize($size);
if ($pdf->GetStringWidth($text) <= $maxWidth) return $size;
$size--;
}
return $minSize;
};
// === Role formatter (fixes "OF" -> "Of", "HEAD" -> "Head"; keeps TA/PTA/HR/KG/IT/DEPT in ALL-CAPS) ===
$formatRole = static function (string $role): string {
// Normalize separators & spaces
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/u', ' ', trim($role));
if ($role === '') return '';
// Special-casing map (case-insensitive keys)
// - "of" must be lower-case
// - "head" should be Title-case
$special = [
'of' => 'of',
'head' => 'Head',
];
$tokens = explode(' ', $role);
$out = [];
foreach ($tokens as $tok) {
if ($tok === '') continue;
// Strip leading/trailing NON-letters for the decision; reattach afterwards
$core = preg_replace('/^\P{L}+|\P{L}+$/u', '', $tok);
$start = strpos($tok, $core);
if ($start === false) { // no letter content
$out[] = ucfirst(mb_strtolower($tok, 'UTF-8'));
continue;
}
$pre = substr($tok, 0, $start);
$suf = substr($tok, $start + strlen($core));
$lw = mb_strtolower($core, 'UTF-8');
if (isset($special[$lw])) {
// Use the exact desired casing from the map
$coreFmt = $special[$lw]; // "of" -> "of", "head" -> "Head"
} elseif (preg_match('/^\p{L}{1,4}$/u', $core)) {
// 1-4 letters -> ALL CAPS (TA, PTA, HR, KG, IT, DEPT)
$coreFmt = mb_strtoupper($core, 'UTF-8');
} else {
// Title-case longer words
$coreFmt = preg_replace_callback(
'/\p{L}+/u',
static fn($m) => mb_strtoupper(mb_substr($m[0], 0, 1, 'UTF-8'), 'UTF-8')
. mb_strtolower(mb_substr($m[0], 1, null, 'UTF-8'), 'UTF-8'),
mb_strtolower($core, 'UTF-8')
);
}
$out[] = $pre . $coreFmt . $suf;
}
return implode(' ', $out);
};
// Class label formatter (KG stays KG; Youth title-cased; otherwise pass through)
$formatClass = static function (string $class): string {
$c = trim($class);
if ($c === '') return '';
$lc = strtolower($c);
if ($lc === 'kg' || $lc === 'kindergarten') return 'KG';
if ($lc === 'youth') return 'Youth';
return $c; // e.g., "1-A", "3", "HS-2"
};
// School name
$schoolName = strtoupper($norm($data['school_name'] ?? 'AL RAHMA SUNDAY SCHOOL'));
$pdf->SetFont('Arial', '', 16);
$pdf->SetXY($x + $padX, $cursorY);
$pdf->MultiCell($w - 2 * $padX, 5, $toPdf($schoolName), 0, 'C');
// Name
$pdf->Ln(9);
$pdf->SetFont('Arial', 'B', 14);
$pdf->SetX($x + $padX);
$name = strtoupper($norm($data['name'] ?? (($data['firstname'] ?? '') . ' ' . ($data['lastname'] ?? 'STAFF'))));
$pdf->Cell($w - 2 * $padX, 6, $toPdf($name), 0, 1, 'C');
// Role (prefer preformatted; then fix with formatter to guarantee "Of"/"Head" etc.)
$roleResolved = $norm((string)($data['role_resolved'] ?? ''));
if ($roleResolved === '') {
// fallback: find first available role-like field
$candidates = [
'role',
'active_role',
'role_name',
function ($r) {
if (!empty($r['roles'])) {
$parts = array_filter(array_map('trim', explode(',', (string)$r['roles'])));
return $parts[0] ?? '';
}
return '';
},
'job_title',
'title',
'position',
'staff_role',
'department_role',
'dept_role'
];
foreach ($candidates as $key) {
$v = '';
if (is_callable($key)) {
$v = (string)$key($data);
} elseif (!empty($data[$key])) {
$v = (string)$data[$key];
}
$v = $norm($v);
if ($v !== '') {
$roleResolved = $v;
break;
}
}
}
// Final fix to ensure exceptions and acronyms are correct even if upstream passed "OF"
if ($roleResolved !== '') {
$roleResolved = $formatRole($roleResolved);
}
// Class
$classRaw = $norm($data['class_section_name'] ?? '');
$class = $formatClass($classRaw);
// Teacher-ish detection (lowercased source; do NOT change printed casing)
$detectSrc = strtolower($norm(
($data['roles_raw'] ?? '') !== '' ? $data['roles_raw'] : (($data['role_name_raw'] ?? '') !== '' ? $data['role_name_raw'] : $roleResolved)
));
$isTeacherish = (strpos($detectSrc, 'teacher') !== false) || preg_match('/\bta\b/', $detectSrc);
// Compose final display (no re-casing beyond $formatRole)
if ($isTeacherish && $class !== '') {
if (strtolower($class) === 'youth') {
$display = 'Youth ' . $roleResolved;
} elseif ($class === 'KG') {
$display = 'KG ' . $roleResolved;
} else {
$display = 'Grade ' . $class . ' ' . $roleResolved;
}
} elseif ($roleResolved !== '') {
$display = $roleResolved;
} elseif ($class !== '') {
$display = $class;
} else {
$display = 'STAFF';
}
// Print role/class
$pdf->Ln(6);
$pdf->SetFont('Arial', '', 14);
$displayOut = $toPdf($display);
$maxTextWidth = $w - 2 * $padX;
$best = $fitText($pdf, $displayOut, 11, 7, $maxTextWidth);
$pdf->SetFont('Arial', '', $best + 4);
if ($pdf->GetStringWidth($displayOut) <= $maxTextWidth) {
$pdf->SetX($x + $padX);
$pdf->Cell($maxTextWidth, 5, $displayOut, 0, 1, 'C');
} else {
$pdf->SetX($x + $padX);
$pdf->MultiCell($maxTextWidth, 5, $displayOut, 0, 'C');
}
// Footer: year
$footerYear = $norm((string)($this->schoolYear ?? ($data['school_year'] ?? '')));
if ($footerYear !== '') {
$pdf->SetFont('Arial', 'I', 14);
$pdf->SetXY($x + $padX, $y + $h - 9);
$pdf->Cell($w - 2 * $padX, 4, $toPdf($footerYear), 0, 0, 'C');
}
}
// ---- Helper: get staff rows (no joins) ----
private function fetchStaffList(?string $schoolYear, array $selectedUserIds = []): array
{
// $this->userModel should be injected/constructed already
return $this->userModel->getNoParentUsersWithRole($schoolYear, $selectedUserIds);
}
// ---- Helper: is this person teacher-ish? (role_name can be CSV) ----
private static function isTeacherish(string $roleCsv): bool
{
$roles = array_filter(array_map('trim', explode(',', strtolower($roleCsv))));
foreach ($roles as $r) {
if ($r === 'teacher' || $r === 'teacher_assistant' || $r === 'teacher assistant') {
return true;
}
}
return false;
}
// ---- Helper: return ONE latest assignment (pref Teacher, else Assistant) and its class name ----
private function getLatestClassForUser(int $userId, ?string $schoolYear = null): ?array
{
// Build a single query that considers teacher or assistant rows,
$qb = $this->db->table('teacher_class tc')
->select('tc.class_section_id, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
->where('tc.teacher_id', $userId);
if (!empty($schoolYear)) {
$qb->where('tc.school_year', $schoolYear);
}
$qb->orderBy('FIELD(tc.position, "main", "ta")', '', false)
->orderBy('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC', '', false)
->limit(1);
if (!empty($schoolYear)) {
$qb->where('tc.school_year', $schoolYear);
}
// Prefer a TEACHER row if one exists, then the latest update.
// MySQL treats boolean expressions as 0/1, so we can order by it.
$qb->orderBy('(tc.teacher_id = ' . $this->db->escape($userId) . ') DESC', '', false)
->orderBy('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC, tc.id DESC', '', false)
->limit(1);
$row = $qb->get()->getRowArray();
if (!$row || empty($row['class_section_id'])) {
return null;
}
return [
'class_section_id' => (int) $row['class_section_id'],
'class_section_name' => $row['class_section_name'] ?? null,
];
}
// ---- Controller: badgeForm (simple + deterministic) ----
public function badgeForm()
{
$request = $this->request;
$schoolYear = $this->schoolYear;
if ($this->schoolYear === null || $schoolYear === '') {
$schoolYear = $this->schoolYear ?? null;
}
// --- Role formatting helpers ---
$formatRole = static function (string $role): string {
// Normalize separators & spaces
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/u', ' ', trim($role));
if ($role === '') return '';
// Special-casing map (case-insensitive keys)
// - "of" must be lower-case
// - "head" should be Title-case
$special = [
'of' => 'of',
'head' => 'Head',
];
$tokens = explode(' ', $role);
$out = [];
foreach ($tokens as $tok) {
if ($tok === '') continue;
// Strip leading/trailing NON-letters for the decision; reattach afterwards
$core = preg_replace('/^\P{L}+|\P{L}+$/u', '', $tok);
$start = strpos($tok, $core);
if ($start === false) { // no letter content
$out[] = ucfirst(mb_strtolower($tok, 'UTF-8'));
continue;
}
$pre = substr($tok, 0, $start);
$suf = substr($tok, $start + strlen($core));
$lw = mb_strtolower($core, 'UTF-8');
if (isset($special[$lw])) {
// Use the exact desired casing from the map
$coreFmt = $special[$lw]; // "of" -> "of", "head" -> "Head"
} elseif (preg_match('/^\p{L}{1,4}$/u', $core)) {
// 1-4 letters -> ALL CAPS (TA, PTA, HR, KG, IT, DEPT)
$coreFmt = mb_strtoupper($core, 'UTF-8');
} else {
// Title-case longer words
$coreFmt = preg_replace_callback(
'/\p{L}+/u',
static fn($m) => mb_strtoupper(mb_substr($m[0], 0, 1, 'UTF-8'), 'UTF-8')
. mb_strtolower(mb_substr($m[0], 1, null, 'UTF-8'), 'UTF-8'),
mb_strtolower($core, 'UTF-8')
);
}
$out[] = $pre . $coreFmt . $suf;
}
return implode(' ', $out);
};
$formatRolesCsv = static function ($csv) use ($formatRole): string {
if ($csv === null) return '';
$parts = array_filter(array_map('trim', explode(',', (string)$csv)), 'strlen');
if (empty($parts)) return '';
$parts = array_map($formatRole, $parts);
return implode(', ', $parts);
};
// --- 2) Selected user IDs: accept array, single value, or CSV ---
$selectedUsers = $request->getGet('user_ids');
if (is_string($selectedUsers)) {
// Handle CSV like "12,34,56"
$selectedUsers = array_filter(array_map('trim', explode(',', $selectedUsers)), 'strlen');
}
if (!is_array($selectedUsers)) {
$selectedUsers = $selectedUsers ? [$selectedUsers] : [];
}
// Normalize to unique ints
$selectedUserIds = array_values(array_unique(array_map(static function ($v) {
return (int) $v;
}, $selectedUsers)));
// --- 3) Base staff rows (non-parent users) ---
// fetchStaffList() should call your model method getNoParentUsersWithRole($schoolYear, $selectedUserIds)
$users = $this->fetchStaffList($schoolYear, $selectedUserIds);
// Normalize keys so the view logic is consistent:
// Expect at least: id (or users.id), firstname, lastname, email, roles (CSV of non-parent roles)
foreach ($users as &$u) {
// Ensure user_id exists
if (!isset($u['user_id'])) {
if (isset($u['users.id'])) {
$u['user_id'] = (int) $u['users.id'];
} elseif (isset($u['id'])) {
$u['user_id'] = (int) $u['id'];
} elseif (isset($u['users_id'])) {
$u['user_id'] = (int) $u['users_id'];
}
}
// Ensure roles string exists (model may already provide it as 'roles')
$rolesStr = $u['roles'] ?? '';
if ($rolesStr === '' && isset($u['role_name'])) {
// fallback if model returned single role
$rolesStr = (string) $u['role_name'];
}
// Keep raw + add formatted CSV
$u['roles_raw'] = $rolesStr;
$u['roles'] = $formatRolesCsv($rolesStr);
// Provide a role_name for legacy view code (pick a representative non-parent role)
if (empty($u['role_name'])) {
$firstRole = '';
if ($rolesStr !== '') {
$parts = array_filter(array_map('trim', explode(',', $rolesStr)));
$firstRole = $parts[0] ?? '';
}
$u['role_name'] = $firstRole;
}
// Keep raw + add formatted single role name
$u['role_name_raw'] = $u['role_name'];
$u['role_name'] = $formatRole((string)$u['role_name']);
// Defaults for class assignment (only filled for teacherish)
$u['class_section_id'] = $u['class_section_id'] ?? null;
$u['class_section_name'] = $u['class_section_name'] ?? null;
}
unset($u);
// --- 4) For teachers/assistants, fetch latest assignment in this year ---
foreach ($users as &$u) {
// Use RAW roles for detection to avoid any formatting side-effects
$rolesDetect = strtolower($u['roles_raw'] ?? ($u['role_name_raw'] ?? ''));
$isTeacherish = (strpos($rolesDetect, 'teacher') !== false) || preg_match('/\bta\b/', $rolesDetect);
if ($isTeacherish && !empty($u['user_id'])) {
$assign = $this->getLatestClassForUser((int) $u['user_id'], $schoolYear);
if ($assign) {
$u['class_section_id'] = $assign['class_section_id'] ?? null;
$u['class_section_name'] = $assign['class_section_name'] ?? null;
}
}
}
unset($u);
// --- 5) Build list of available school years (robust) ---
$db = \Config\Database::connect();
$schoolYears = [];
// Prefer teacher_class if available
try {
if (method_exists($db, 'tableExists') ? $db->tableExists('teacher_class') : true) {
$q1 = $db->table('teacher_class')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$schoolYears = array_column($q1, 'school_year');
}
} catch (\Throwable $e) {
// ignore and try fallback
}
// Fallback to user_roles if teacher_class didn't produce anything
if (empty($schoolYears)) {
try {
if (method_exists($db, 'tableExists') ? $db->tableExists('user_roles') : true) {
$fields = $db->getFieldNames('user_roles');
if (in_array('school_year', $fields, true)) {
$q2 = $db->table('user_roles')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$schoolYears = array_column($q2, 'school_year');
}
}
} catch (\Throwable $e) {
// swallow; leave $schoolYears empty
}
}
// --- 6) Active role tab (defensive default for the view) ---
$rolesTabs = [
'teacher' => 'Teachers',
'ta' => 'Teacher Assistants',
'admin' => 'Admins',
'staff' => 'Staff',
];
$requestedRole = $request->getGet('active_role') ?? $request->getPost('active_role') ?? null;
$activeRole = array_key_exists((string)$requestedRole, $rolesTabs) ? (string)$requestedRole : 'teacher';
// --- 7) Pack data for the view ---
$data = [
'users' => $users,
'schoolYears' => $schoolYears,
'selectedYear' => $schoolYear,
'selectedUserIds' => $selectedUserIds,
'rolesTabs' => $rolesTabs,
'active_role' => $activeRole, // prevents "Undefined array key 'active_role'"
];
return view('printables_reports/badge_form', $data);
}
protected function getUserInfoById($id, $schoolYear = null)
{
// ---- A) Resolve the user row
$u = $this->db->table('users')
->select('id AS user_id, firstname, lastname')
->where('id', $id)
->limit(1)
->get()->getRowArray();
if (!$u) {
return null;
}
$userId = (int) $u['user_id'];
$fullname = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
// ---- B) Get roles (non-parent) for this user
$rolesRows = $this->db->table('user_roles ur')
->select('r.id AS role_id, r.name AS role_name')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('ur.user_id', $userId)
->get()->getResultArray();
$allRoles = [];
foreach ($rolesRows as $rr) {
$name = trim((string)($rr['role_name'] ?? ''));
if ($name !== '' && strtolower($name) !== 'parent') {
// normalize to "Ucwords"
$allRoles[] = ucwords(strtolower($name));
}
}
$allRoles = array_values(array_unique($allRoles));
$rolesCsv = $allRoles ? implode(', ', $allRoles) : '';
// ---- C) Latest assignment from teacher_class
$tc = $this->db->table('teacher_class')
->select('class_section_id, updated_at, id, position, school_year')
->where('teacher_id', $userId);
if (!empty($schoolYear)) {
$tc->where('school_year', $schoolYear);
}
$assignment = $tc->orderBy('IFNULL(updated_at, "1970-01-01 00:00:00") DESC, id DESC', '', false)
->limit(1)
->get()->getRowArray();
$classId = $assignment['class_section_id'] ?? null;
$position = strtolower(trim((string)($assignment['position'] ?? '')));
$className = null;
if (!empty($classId)) {
$className = $this->resolveClassName($this->db, $classId);
}
// ---- D) Decide primary role label
$roleLabel = '';
if ($position === 'ta' || $position === 'teacher_assistant' || $position === 'assistant') {
$roleLabel = 'Teacher Assistant';
} elseif ($position === 'teacher') {
$roleLabel = 'Teacher';
} elseif (!empty($allRoles)) {
// Use first role from normalized list
$roleLabel = $allRoles[0];
} else {
$roleLabel = 'Staff';
}
// ---- E) Logos
$schoolLogo = $this->firstExisting([
FCPATH . 'assets/images/school_logo.png',
FCPATH . 'assets/images/logo.png',
]);
$isglLogo = $this->firstExisting([
FCPATH . 'assets/images/isgl_logo.png',
FCPATH . 'assets/images/isgl.png',
]);
// ---- F) Build job title / display title
$jobTitle = '';
if (!empty($roleLabel) && !empty($className)) {
$jobTitle = "{$roleLabel} - {$className}";
} elseif (!empty($roleLabel)) {
$jobTitle = $roleLabel;
} elseif (!empty($className)) {
$jobTitle = $className;
}
return [
'name' => $fullname !== '' ? $fullname : 'STAFF',
'role' => $roleLabel, // primary label
'roles' => $rolesCsv, // all roles (comma-separated)
'class_section_id' => $classId ? (int)$classId : null,
'class_section_name' => $className,
'job_title' => $jobTitle,
'school_name' => 'Al Rahma Sunday School',
'school_id' => $userId,
'school_logo' => $schoolLogo,
'isgl_logo' => $isglLogo,
'school_year' => $assignment['school_year'] ?? ($schoolYear ?? null),
];
}
}
@@ -0,0 +1,256 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserModel;
use App\Services\EmailService;
class BroadcastEmailController extends BaseController
{
protected UserModel $userModel;
protected EmailService $mailer;
public function __construct()
{
$this->userModel = new UserModel();
$this->mailer = new EmailService(); // use your existing mailer unmodified
}
public function index()
{
helper(['form']);
// Parents via your role-based function
$parents = $this->userModel->getParents();
$parents = array_values(array_filter($parents, static fn($p) => !empty($p['email'])));
// Sender list from MAIL_SENDERS directly (no change to EmailService)
$fromOptions = $this->senderOptionsFromEnv();
return view('administrator/broadcast_email', [
'parents' => $parents,
'fromOptions' => $fromOptions, // [['key'=>'general','label'=>'Al Rahma Office <office@...>'], ...]
]);
}
public function send()
{
helper(['form']);
if (strtolower($this->request->getMethod()) !== 'post') {
return redirect()->to(site_url('admin/broadcast-email'));
}
$isTestOnly = $this->request->getPost('send_test_only') !== null;
$mode = (string) $this->request->getPost('mode'); // 'personalized' | 'standard'
$subject = trim((string) $this->request->getPost('subject'));
$fromKey = trim((string) $this->request->getPost('from_key') ?: 'general');
$body = (string) $this->request->getPost('body_html');
$body = $this->sanitizeEmailHtml($body);
// Layout options
$wrapLayout = (bool) $this->request->getPost('wrap_layout');
$preheader = (string) ($this->request->getPost('preheader') ?? '');
$ctaText = (string) ($this->request->getPost('cta_text') ?? '');
$ctaUrl = (string) ($this->request->getPost('cta_url') ?? '');
$testEmail = trim((string) $this->request->getPost('test_email'));
if ($subject === '' || $body === '') {
return redirect()->back()->withInput()->with('error', 'Subject and Body are required.');
}
$isPersonalized = ($mode === 'personalized');
// --- TEST ONLY ---
if ($isTestOnly) {
if ($testEmail === '') {
return redirect()->back()->withInput()->with('error', 'Provide a test email address.');
}
$recipientName = 'Parent';
$html = $this->composeEmailHtml(
$wrapLayout,
$subject,
$body,
$recipientName,
$preheader,
$ctaText,
$ctaUrl,
$isPersonalized
);
$ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey);
return redirect()->back()->with(
$ok ? 'message' : 'error',
$ok ? "Test email sent to {$testEmail}." : "Test email failed (mailer->send() returned false). Check logs."
);
}
// --- BROADCAST ---
$rawIds = (array) ($this->request->getPost('parent_ids') ?? []);
$ids = [];
foreach ($rawIds as $v) {
if (is_string($v) && strpos($v, ',') !== false) {
$ids = array_merge($ids, array_map('intval', explode(',', $v)));
} else {
$ids[] = (int) $v;
}
}
$ids = array_values(array_unique(array_filter($ids)));
if (empty($ids)) {
return redirect()->back()->withInput()->with('error', 'Please select at least one parent.');
}
$rows = model(\App\Models\UserModel::class)
->select('users.id, users.email, CONCAT(users.firstname, " ", users.lastname) AS name')
->whereIn('users.id', $ids)
->where('users.email IS NOT NULL AND users.email != ""')
->findAll();
if (empty($rows)) {
return redirect()->back()->withInput()->with('error', 'No valid parent emails found.');
}
$stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode];
foreach ($rows as $r) {
$stats['attempted']++;
$recipientName = $r['name'] ?: 'Parent';
$html = $this->composeEmailHtml(
$wrapLayout,
$subject,
$body,
$recipientName,
$preheader,
$ctaText,
$ctaUrl,
$isPersonalized
);
$ok = $this->mailer->send($r['email'], $subject, $html, $fromKey);
$ok ? $stats['sent']++ : $stats['failed']++;
}
$msg = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}.";
return redirect()->to(site_url('admin/broadcast-email'))
->with($stats['failed'] > 0 ? 'error' : 'message', $msg);
}
/**
* Build HTML: optionally wrap $body inside your view('layout/email_layout', ...).
*/
private function composeEmailHtml(
bool $wrap,
string $subject,
string $body,
string $recipientName,
string $preheader = '',
string $ctaText = '',
string $ctaUrl = '',
bool $doPersonalize = true
): string {
$content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body;
if (!$wrap) {
return $content; // raw body
}
// Render a CHILD view that defines the section your layout expects
return view('emails/broadcast_wrapper', [
'subject' => $subject,
'content' => $content,
// pass more if you later wire them in your layout
'preheader' => $preheader,
'cta_text' => $ctaText,
'cta_url' => $ctaUrl,
]);
}
private function sanitizeEmailHtml(string $html): string
{
// Remove <script> and <iframe> blocks (cheap and effective for admin inputs)
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\1>#is', '', $html);
// Optionally strip on* event handlers (onclick, etc.)
$html = preg_replace('/\son\w+="[^"]*"/i', '', $html);
$html = preg_replace("/\son\w+='[^']*'/i", '', $html);
return $html;
}
/**
* Read MAIL_SENDERS from .env so we dont need changes in EmailService.
* Returns [['key' => 'general', 'label' => 'Al Rahma Office <office@...>'], ...]
*/
private function senderOptionsFromEnv(): array
{
$json = env('MAIL_SENDERS', '{}');
$arr = json_decode($json, true);
if (!is_array($arr) || empty($arr)) {
// Fallback to SMTP_USER as a single "general" option
$smtpUser = getenv('SMTP_USER') ?: '';
$name = 'Al Rahma Sunday School';
return [['key' => 'general', 'label' => $name . ($smtpUser ? " <{$smtpUser}>" : '')]];
}
$out = [];
foreach ($arr as $key => $info) {
$nm = $info['name'] ?? 'Sender';
$em = $info['email'] ?? '';
$out[] = ['key' => (string)$key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))];
}
return $out;
}
public function uploadImage()
{
if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON(['success' => false, 'error' => 'Method not allowed']);
}
$file = $this->request->getFile('image');
if (!$file || !$file->isValid()) {
return $this->response->setStatusCode(400)->setJSON(['success' => false, 'error' => 'No image uploaded']);
}
$mime = strtolower((string) $file->getMimeType());
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
if (!isset($allowed[$mime])) {
return $this->response->setStatusCode(415)->setJSON(['success' => false, 'error' => 'Unsupported image type']);
}
if ($file->getSize() > 5 * 1024 * 1024) {
return $this->response->setStatusCode(413)->setJSON(['success' => false, 'error' => 'Image too large (max 5MB)']);
}
$targetDir = rtrim(FCPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'email';
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$ext = $allowed[$mime];
$newName = uniqid('em_', true) . '.' . $ext;
$file->move($targetDir, $newName, true);
helper('url');
$url = base_url('uploads/email/' . $newName);
// 👈 send the rotated token back both in JSON and a response header
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'url' => $url,
'csrf_hash' => $newHash,
]);
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\StudentModel;
use App\Models\TeacherModel;
use App\Models\ConfigurationModel;
class ClassController extends BaseController
{
protected $classsectionModel;
protected $studentModel;
protected $teacherModel;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $db;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
{
$this->db = \Config\Database::connect();
$this->classsectionModel = new ClassSectionModel();
// Load necessary models
$this->studentModel = new StudentModel();
$this->teacherModel = new TeacherModel();
$this->configModel = new ConfigurationModel();
// Get the semester from the configuration table
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function index()
{
// Retrieve all classes from the database
$classsection = $this->classsectionModel->getAllClasses();
// Pass the classes to the view
return view('administrator/class_section', ['classsection' => $classsection]);
}
public function create()
{
// Show the form to create a new class
return view('administrator/create_class');
}
public function store()
{
// Handle the form submission to create a new class
$data = [
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description'),
];
//$this->classModel->createClass($data);
return redirect()->to('/administrator/classes');
}
public function edit($id)
{
// Retrieve the class details to edit
//$class = $this->ClassSectionModel->getClassById($id);
//return view('administrator/edit_class', ['class' => $class]);
}
public function updateClass($id)
{
// Handle the form submission to update an existing class
$data = [
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description'),
];
//$this->classModel->updateClass($id, $data);
return redirect()->to('/administrator/classes');
}
public function destroyClass($id)
{
// Delete the class
//$this->classModel->deleteClass($id);
return redirect()->to('/administrator/classes');
}
public function addClasses()
{
// Include the shared database connection file
$file = __DIR__ . '/../db_connection.php';
if (file_exists($file)) {
require_once $file;
} else {
die("Error: Could not find the required file '$file'.");
}
$classes = [
['class_name' => 'Class 1', 'teacher_id' => 1, 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 2', 'teacher_id' => 2, 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 3', 'teacher_id' => 3, 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 4', 'teacher_id' => 4, 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 5', 'teacher_id' => 5, 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
['class_name' => 'Class 6', 'teacher_id' => 6, 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Class 7', 'teacher_id' => 7, 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Class 8', 'teacher_id' => 8, 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Class 9', 'teacher_id' => 9, 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
['class_name' => 'Youth', 'teacher_id' => 10, 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
];
foreach ($classes as $class) {
$stmt = $conn->prepare("INSERT INTO classes (class_name, teacher_id, schedule, capacity) VALUES (?, ?, ?, ?)");
if (!$stmt) {
die("Prepare failed: (" . $conn->errno . ") " . $conn->error);
}
$stmt->bind_param("sisi", $class['class_name'], $class['teacher_id'], $class['schedule'], $class['capacity']);
$stmt->execute();
$stmt->close();
}
$conn->close();
return redirect()->to('/parent/classes')->with('success', 'Classes added successfully.');
}
public function classAttendance($class_section_id)
{
// Get the teacher's ID from the session
$teacherId = session()->get('user_id');
// Fetch teacher data
$teacherData = $this->teacherModel->find($teacherId);
// Fetch students for the class
$studentsData = $this->studentModel->join('student_class', 'students.id = student_class.student_id')
->where('student_class.class_section_id', $class_section_id)
->select('students.*, student_class.class_section_id')
->findAll();
// Get class name (assuming it's stored in the students data)
$className = !empty($studentsData) && isset($studentsData[0]['class_name']) ? $studentsData[0]['class_name'] : 'Class Name Not Available';
// Check if the teacher data and students data are available
if (empty($teacherData) || empty($studentsData)) {
return redirect()->to('/teacher/classes')->with('error', 'No data found for this class.');
}
// Pass data to the view
return view('/teacher/classes', [
'teacher_name' => $teacherData['teacher_first'] . ' ' . $teacherData['teacher_last'],
'students' => $studentsData,
'class_name' => $className,
'semester' => $this->semester,
'class_section_id' => $class_section_id, // Pass the class_id for attendance update forms
]);
}
}
@@ -0,0 +1,307 @@
<?php
namespace App\Controllers\View;
class ClassPrepController extends PrintablesBaseController
{
// Compute per-student sticker counts for entire schoolYear (optionally semester).
protected function computeStickerCountsAll(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, ?string $semester = null): array
{
return $this->computeStickerCounts($schoolYear, null, $semester);
}
// Compute per-student sticker counts for one class section.
protected function computeStickerCountsForClass(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, int $classSectionId, ?string $semester = null): array
{
return $this->computeStickerCounts($schoolYear, $classSectionId, $semester);
}
protected function computeStickerCounts(
string $schoolYear,
?int $classSectionId,
?string $semester
): array {
$specialMap = ['KG' => 1, '1-A' => 3, '1-B' => 4, '2-A' => 5, '2-B' => 5, '9' => 2];
$isUpperBlock = static fn(string $g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
$b = $this->db->table('student_class sc')
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
->join('students s', 's.id = sc.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
->join('classes c', 'c.id = cs.class_id', 'inner')
->where('sc.school_year', $schoolYear);
if (!empty($classSectionId)) {
$b->where('sc.class_section_id', $classSectionId);
}
$rows = $b->get()->getResultArray();
// Exclude Youth and KG (case-insensitive; also ignore variants like youth-*, KG-*)
$rows = array_values(array_filter($rows, static function ($r) {
$g = trim((string) $r['grade_label']);
return $g !== ''
&& !preg_match('/^youth(?:\b|-)/i', $g)
&& !preg_match('/^kg(?:\b|-)/i', $g);
}));
$students = [];
$total = 0;
foreach ($rows as $r) {
$g = trim((string) $r['grade_label']);
$isNew = ((int) $r['is_new']) === 1;
// Primary-only rules
if (array_key_exists($g, $specialMap)) {
$p = $specialMap[$g];
} elseif ($isUpperBlock($g)) {
$p = $isNew ? 4 : ($isGrade5($g) ? 3 : 2);
} else {
$p = 4;
}
$total += $p;
$students[] = [
'student_id' => (int) $r['student_id'],
'firstname' => trim($r['firstname']),
'lastname' => trim($r['lastname']),
'grade_label' => $g,
'primary_count' => $p,
];
}
return [
'students' => $students,
'totals' => [
'stickers' => $total,
'students' => count($students),
],
];
}
/**
* Compute sticker counts per student (no file I/O).
*
* @param BaseConnection $db
* @param string $schoolYear e.g. "2025-2026"
* @param string|null $semester e.g. "Fall" (optional filter)
* @return array{
* students: array<int, array{
* student_id:int,
* firstname:string,
* lastname:string,
* grade_label:string,
* primary_count:int,
* secondary_count:int
* }>,
* totals: array{primary:int, secondary:int, students:int}
* }
*/
private function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array
{
// --- Sticker rules helpers ----------------------------------------------
$specialMap = [
'KG' => 1,
'1-A' => 3,
'1-B' => 4,
'2-A' => 5,
'2-B' => 5,
'9' => 2,
];
$isUpperBlock = static function (string $g): bool {
// 3-A, 3-B, 4-A, 4-B, 5-A, 5-B, 6, 7, 8 (also allow plain 3/4/5/6/7/8)
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
};
$isGrade5 = static function (string $g): bool {
// "5" OR "5-A"/"5-B"
return (bool) preg_match('/^5(\-|$)/', trim($g));
};
// --- Pull roster: student_class -> classSection -> classes -> students ---
$builder = $this->db->table('student_class sc')
->select([
'sc.student_id',
's.firstname',
's.lastname',
's.is_new',
'cs.class_section_name AS grade_label',
])
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->join('classes c', 'c.id = cs.class_id')
->where('sc.school_year', $schoolYear);
$rows = $builder->get()->getResultArray();
// Filter out Youth (any case; includes youth-*), and empty labels
$rows = array_values(array_filter($rows, static function ($r) {
$g = trim((string) $r['grade_label']);
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
}));
$out = [];
$totalPrimary = 0;
$totalSecondary = 0;
foreach ($rows as $r) {
$first = trim((string) $r['firstname']);
$last = trim((string) $r['lastname']);
$grade = trim((string) $r['grade_label']);
$isNew = ((int) $r['is_new']) === 1;
$primary = 0;
$secondary = 0;
// 1) Special fixed grades
if (array_key_exists($grade, $specialMap)) {
$primary = (int) $specialMap[$grade];
$secondary = 0;
}
// 2) Upper block 3..8
elseif ($isUpperBlock($grade)) {
if ($isNew) {
$primary = 4;
$secondary = 0;
} else {
$primary = $isGrade5($grade) ? 3 : 2;
$secondary = max(0, 4 - $primary);
}
}
// 3) Fallback for other non-Youth labels (e.g., "1", "2", "10", "11")
else {
$primary = 4;
$secondary = 0;
}
$totalPrimary += $primary;
$totalSecondary += $secondary;
$out[] = [
'student_id' => (int) $r['student_id'],
'firstname' => $first,
'lastname' => $last,
'grade_label' => $grade,
'primary_count' => $primary,
'secondary_count' => $secondary,
];
}
return [
'students' => $out,
'totals' => [
'primary' => $totalPrimary,
'secondary' => $totalSecondary,
'students' => count($out),
],
];
}
protected function getStickerCountsPerStudentForClass(
string $schoolYear,
int $classSectionId,
?string $semester = null
): array {
// Reuse exact helpers/rules from getStickerCountsPerStudent()
$specialMap = [
'1-A' => 3,
'1-B' => 4,
'2-A' => 5,
'2-B' => 5,
'9' => 2,
];
$isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
$b = $this->db->table('student_class sc')
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->join('classes c', 'c.id = cs.class_id')
->where('sc.school_year', $schoolYear)
->where('sc.class_section_id', $classSectionId);
$rows = $b->get()->getResultArray();
$rows = array_values(array_filter($rows, static function ($r) {
$g = trim((string) $r['grade_label']);
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
}));
$out = [];
$tp = 0;
$ts = 0;
foreach ($rows as $r) {
$g = trim((string) $r['grade_label']);
$isNew = ((int) $r['is_new']) === 1;
$p = 0;
$s = 0;
if (isset($specialMap[$g])) {
$p = $specialMap[$g];
} elseif ($isUpperBlock($g)) {
if ($isNew) {
$p = 4;
} else {
$p = $isGrade5($g) ? 3 : 2;
$s = max(0, 4 - $p);
}
} else {
$p = 4;
}
$tp += $p;
$ts += $s;
$out[] = [
'student_id' => (int) $r['student_id'],
'firstname' => trim($r['firstname']),
'lastname' => trim($r['lastname']),
'grade_label' => $g,
'primary_count' => $p,
'secondary_count' => $s,
];
}
return ['students' => $out, 'totals' => ['primary' => $tp, 'secondary' => $ts, 'students' => count($out)]];
}
public function previewStickerCounts()
{
// Optional: allow class_id filter; if present, preview only that class
$classId = (int) ($this->request->getGet('class_id') ?? 0);
if ($classId > 0) {
// same join as getStickerCountsPerStudent but add class filter
$result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester);
} else {
// whole school year
$result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester);
}
return $this->response->setJSON([
'status' => 'ok',
'data' => $result
]);
}
public function studentsByClass(int $classSectionId)
{
$rows = $this->studentModel
->select('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name AS registration_grade')
->join('student_class sc', 'sc.student_id = students.id', 'inner')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
->where('sc.school_year', $this->schoolYear)
->where('sc.class_section_id', $classSectionId)
->groupBy('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->findAll(1000);
return $this->response->setJSON($rows);
}
}
@@ -0,0 +1,608 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\StudentClassModel;
use App\Models\ClassPreparationLogModel;
use App\Models\ConfigurationModel;
use App\Models\ClassSectionModel;
use App\Models\ClassPrepAdjustmentModel;
use App\Models\UserModel;
class ClassPreparationController extends BaseController
{
protected $studentClassModel;
protected $prepLogModel;
protected $classSectionModel;
protected $configModel;
protected $userModel;
protected $db;
protected $schoolYear;
protected $semester;
protected $adjustmentModel;
/** cache for roster presence per term */
private array $rosterPresenceCache = [];
// Inside ClassPreparationController (class scope, not inside a method)
private array $allowedPrepCategories = [
'Grade Box',
'Large Table',
'Regular Chair',
'Small Chair',
'Small Table',
'Teacher Chair',
'Trash Bin',
'White Board',
];
public function __construct()
{
$this->studentClassModel = new StudentClassModel();
$this->prepLogModel = new ClassPreparationLogModel();
$this->classSectionModel = new ClassSectionModel();
$this->configModel = new ConfigurationModel();
$this->adjustmentModel = new ClassPrepAdjustmentModel();
$this->userModel = new UserModel();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
}
public function index()
{
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$semParam = $this->request->getGet('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$allowed = $this->allowedPrepCategories;
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
// 1) Get student count per class-section (distinct students, correct semester)
$scQ = $this->studentClassModel
->select('class_section_id, COUNT(DISTINCT student_id) AS student_count')
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$scQ->where('semester', $semester);
}
$classSections = $scQ->groupBy('class_section_id')->findAll();
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
// Seed totals with allowed categories for stable ordering
$requiredTotals = array_fill_keys($allowed, 0);
$prepResults = [];
// 3) Build prep per section (once!)
foreach ($classSections as $section) {
$classSectionId = $section['class_section_id'];
$studentCount = (int)$section['student_count'];
$classLevel = $this->getClassLevelBySection($classSectionId);
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
// Calculate required items (whitelist inside)
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
foreach ($rawAdjustments as $a) {
$item = $a['item_name'];
$delta = (int)$a['adjustment'];
if (array_key_exists($item, $adjMap)) {
$adjMap[$item] = $delta;
}
if (isset($baseItems[$item])) {
$baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
} elseif (in_array($item, $allowed, true)) {
$baseItems[$item] = max(0, $delta);
}
}
// 4) Accumulate global totals (allowed only)
foreach ($allowed as $cat) {
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
}
// 5) Compare with last snapshot; do not save here — only on print or explicit API
$oldSnap = $this->prepLogModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
// 6) Push exactly ONCE
$prepResults[] = [
'class_section' => $className,
'class_section_id' => $classSectionId,
'student_count' => $studentCount,
'class_level' => $classLevel,
'prep_items' => $baseItems,
'needs_print' => $hasChanged,
'last_printed_at' => $oldSnap['created_at'] ?? null,
'adjustments' => $adjMap,
];
}
// 7) Shortages (compare totals vs inventory)
$shortages = [];
foreach ($requiredTotals as $item => $reqQty) {
$have = (int)($inventoryMap[$item] ?? 0);
if ($have < (int)$reqQty) {
$shortages[$item] = (int)$reqQty - $have;
}
}
return view('class_prep/list', [
'prepResults' => $prepResults,
'schoolYear' => $schoolYear,
'semester' => $semester,
'shortages' => $shortages,
'totalNeeded' => $requiredTotals,
'available' => $inventoryMap,
// For temporary debugging, you can pass these too:
// 'joinGood' => $joinGood, 'nameGood' => $nameGood, 'joinCond' => $joinCond, 'nameCond' => $nameCond,
]);
}
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
{
$schoolYear = $schoolYear ?? $this->schoolYear;
// positions: 'main' and 'ta' (per your new schema)
$row = $this->db->table('teacher_class')
->select('COUNT(*) AS cnt')
->where('class_section_id', $classSectionId)
->whereIn('position', ['main', 'ta'])
->where('school_year', $schoolYear)
->get()->getRowArray();
return (int)($row['cnt'] ?? 0);
}
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
{
// 1) Stable keys: your whitelist, all zero to start
$allowed = $this->allowedPrepCategories;
$items = array_fill_keys($allowed, 0);
// 2) Fetch only the classroom categories you care about (optionally with grade bounds)
$categories = $this->db->table('inventory_categories')
->select('name, grade_min, grade_max')
->where('type', 'classroom')
->whereIn('name', $allowed)
->orderBy('name')
->get()
->getResultArray();
// 3) Rules
$isLowerGrades = in_array($classLevel, [1, 2], true);
$teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear);
foreach ($categories as $cat) {
$name = $cat['name']; // e.g., 'Small Table'
$gradeMin = $cat['grade_min']; // may be null
$gradeMax = $cat['grade_max']; // may be null
// Respect optional bounds
if ($gradeMin !== null && $classLevel < (int)$gradeMin) {
$items[$name] = 0;
continue;
}
if ($gradeMax !== null && $classLevel > (int)$gradeMax) {
$items[$name] = 0;
continue;
}
$qty = 0;
switch (strtolower($name)) {
case 'small table':
$qty = $isLowerGrades ? (int)ceil($students / 3) : 0;
break;
case 'large table':
$qty = $isLowerGrades ? 0 : (int)ceil($students / 4);
break;
case 'small chair':
$qty = $isLowerGrades ? $students : 0;
break;
case 'regular chair':
$qty = $isLowerGrades ? 0 : $students;
break;
case 'teacher chair':
$qty = (int)$teacherCount;
break;
case 'trash bin':
case 'white board':
case 'grade box':
$qty = 1;
break;
default:
$qty = 0;
}
// Only set for allowed keys (guards against unexpected DB rows)
if (array_key_exists($name, $items)) {
$items[$name] = $qty;
}
}
return $items;
}
public function saveAdjustment()
{
$data = $this->request->getPost();
$sectionId = $data['class_section_id'];
$schoolYear = $data['school_year'];
$adjustments = $data['adjustments'] ?? [];
foreach ($adjustments as $itemName => $adjustment) {
$row = $this->adjustmentModel
->where('class_section_id', $sectionId)
->where('school_year', $schoolYear)
->where('item_name', $itemName)
->first();
if ($row) {
// Update
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
} else {
// Insert
$this->adjustmentModel->insert([
'class_section_id' => $sectionId,
'item_name' => $itemName,
'adjustment' => (int)$adjustment,
'school_year' => $schoolYear,
'adjustable' => 1,
]);
}
}
return redirect()->to(site_url('class-prep?school_year=' . urlencode($schoolYear)))
->with('success', 'Adjustments saved successfully.');
}
/* ---------- helpers ---------- */
private function hasPrepChanged(array $new, array $old): bool
{
ksort($new);
ksort($old);
return json_encode($new) !== json_encode($old);
}
public function print($classSectionId, $schoolYear)
{
$semParam = $this->request->getGet('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester;
$limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester);
// Friendly label (if you have this helper)
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
// Distinct student count for this term
$studentQ = $this->studentClassModel
->select('COUNT(DISTINCT student_id) AS cnt')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$studentQ->where('semester', $semester);
}
$studentRow = $studentQ->first();
$studentCount = (int)($studentRow['cnt'] ?? 0);
// Live calc + adjustments
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
foreach ($rawAdjustments as $a) {
$item = $a['item_name'];
$delta = (int)$a['adjustment'];
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
}
// Record a snapshot at print time as the new baseline
$this->prepLogModel->insert([
'class_section_id' => (string)$classSectionId,
'class_section' => $className,
'school_year' => $schoolYear,
'prep_data' => json_encode($items),
'created_at' => utc_now(),
]);
// Return PRINT view (HTML) that auto-opens the print dialog
return view('class_prep/print', [
'classSectionId' => $classSectionId,
'classSection' => $className,
'schoolYear' => $schoolYear,
'prepItems' => $items,
]);
}
/**
* API: Return class prep data and change flags for all sections in a year.
* GET: school_year
* Response: { ok, schoolYear, results:[{ class_section_id, class_section, student_count, class_level, prep_items, needs_print, last_printed_at }], totals, shortages }
*/
public function apiList()
{
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$semParam = $this->request->getGet('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$allowed = $this->allowedPrepCategories;
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
// Student counts
$scQ = $this->studentClassModel
->select('class_section_id, COUNT(DISTINCT student_id) AS student_count')
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$scQ->where('semester', $semester);
}
$classSections = $scQ->groupBy('class_section_id')->findAll();
// Build inventory availability maps
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
$requiredTotals = array_fill_keys($allowed, 0);
$results = [];
foreach ($classSections as $row) {
$classSectionId = (string)$row['class_section_id'];
$studentCount = (int)$row['student_count'];
$classLevel = $this->getClassLevelBySection($classSectionId);
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
foreach ($rawAdjustments as $a) {
$item = $a['item_name'];
$delta = (int)$a['adjustment'];
if (array_key_exists($item, $adjMap)) $adjMap[$item] = $delta;
if (isset($baseItems[$item])) $baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
}
foreach ($allowed as $cat) {
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
}
$oldSnap = $this->prepLogModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
$results[] = [
'class_section' => $className,
'class_section_id' => $classSectionId,
'student_count' => $studentCount,
'class_level' => $classLevel,
'prep_items' => $baseItems,
'needs_print' => $hasChanged,
'last_printed_at' => $oldSnap['created_at'] ?? null,
'adjustments' => $adjMap,
];
}
// Shortages
$shortages = [];
foreach ($requiredTotals as $item => $reqQty) {
$have = (int)($inventoryMap[$item] ?? 0);
if ($have < (int)$reqQty) $shortages[$item] = (int)$reqQty - $have;
}
return $this->response->setJSON([
'ok' => true,
'schoolYear' => $schoolYear,
'results' => $results,
'totals' => $requiredTotals,
'shortages' => $shortages,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
/**
* API: Mark selected classes as printed (save baseline snapshot).
* POST: school_year, class_section_ids[]
*/
public function apiMarkPrinted()
{
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
$semParam = $this->request->getPost('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
$ids = $this->request->getPost('class_section_ids') ?? [];
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
$now = utc_now();
$count = 0;
foreach ($ids as $classSectionId) {
$studentQ = $this->studentClassModel
->select('COUNT(DISTINCT student_id) AS cnt')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$studentQ->where('semester', $semester);
}
$studentRow = $studentQ->first();
$studentCount = (int)($studentRow['cnt'] ?? 0);
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
foreach ($rawAdjustments as $a) {
$item = $a['item_name'];
$delta = (int)$a['adjustment'];
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
}
try {
$this->prepLogModel->insert([
'class_section_id' => (string)$classSectionId,
'class_section' => $className,
'school_year' => $schoolYear,
'prep_data' => json_encode($items),
'created_at' => $now,
]);
$count++;
} catch (\Throwable $e) {
// ignore and continue
}
}
return $this->response->setJSON([
'ok' => true,
'updated' => $count,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
private function getClassLevelBySection(string $classSectionId): int
{
// Prefer the human-readable section name for grade parsing.
$sectionName = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
$label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId;
// If it's clearly Kindergarten (KG/K)
if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $label)) {
return 1; // treat Kindergarten as lower grade
}
// Remove non-digits, then infer by leading digit
$numValue = (int) preg_replace('/\D/', '', $label);
if ($numValue > 0 && $numValue < 30) {
$firstDigit = (int) substr((string) $numValue, 0, 1);
return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3);
}
// Default to upper grades (3+)
return 3;
}
/**
* Returns true if student_class has any rows for the given school year.
* The semester argument is ignored because assignments are tracked per year.
*/
private function hasRosterForTerm(string $schoolYear, string $semester): bool
{
$year = trim((string)$schoolYear);
if ($year === '') {
return false;
}
if (array_key_exists($year, $this->rosterPresenceCache)) {
return $this->rosterPresenceCache[$year];
}
$cnt = $this->db->table('student_class')
->where('school_year', $year)
->countAllResults();
$this->rosterPresenceCache[$year] = $cnt > 0;
return $this->rosterPresenceCache[$year];
}
private function hasRosterForSemester(string $schoolYear, string $semester): bool
{
$year = trim((string)$schoolYear);
$sem = trim((string)$semester);
if ($year === '' || $sem === '') {
return false;
}
$key = sprintf('sem:%s:%s', $year, $sem);
if (array_key_exists($key, $this->rosterPresenceCache)) {
return $this->rosterPresenceCache[$key];
}
$cnt = $this->db->table('student_class')
->where('school_year', $year)
->where('semester', $sem)
->countAllResults();
$this->rosterPresenceCache[$key] = $cnt > 0;
return $this->rosterPresenceCache[$key];
}
private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
{
$inventoryMap = array_fill_keys($allowed, 0);
$joinRows = $this->db->table('inventory_items ii')
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
->where('ii.type', 'classroom')
->where('ii.school_year', $schoolYear)
->whereIn('ic.name', $allowed);
if ($limitToSemester && $semester !== '') {
$joinRows->where('ii.semester', $semester);
}
$joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray();
foreach ($joinRows as $r) {
$name = (string)($r['item_name'] ?? '');
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
}
}
$nameRows = $this->db->table('inventory_items')
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
->where('type', 'classroom')
->where('school_year', $schoolYear)
->whereIn('name', $allowed);
if ($limitToSemester && $semester !== '') {
$nameRows->where('semester', $semester);
}
$nameRows = $nameRows->groupBy('name')->get()->getResultArray();
foreach ($nameRows as $r) {
$name = (string)($r['item_name'] ?? '');
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
}
}
return $inventoryMap;
}
}
@@ -0,0 +1,172 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\StudentModel;
use App\Models\FamilyModel;
use App\Models\FamilyStudentModel;
use App\Models\FamilyGuardianModel;
use App\Models\FamilyCommPrefModel; // optional
use App\Models\EmailTemplateModel;
use App\Models\CommunicationLogModel;
class CommunicationController extends BaseController
{
protected StudentModel $studentModel;
protected FamilyModel $familyModel;
protected FamilyStudentModel $fsModel;
protected FamilyGuardianModel $fgModel;
protected ?FamilyCommPrefModel $fcpModel = null;
protected EmailTemplateModel $templateModel;
protected CommunicationLogModel $logModel;
public function __construct()
{
$this->studentModel = new StudentModel();
$this->familyModel = new FamilyModel();
$this->fsModel = new FamilyStudentModel();
$this->fgModel = new FamilyGuardianModel();
if (class_exists(FamilyCommPrefModel::class)) {
$this->fcpModel = new FamilyCommPrefModel();
}
$this->templateModel = new EmailTemplateModel();
$this->logModel = new CommunicationLogModel();
}
public function index()
{
$students = $this->studentModel->orderBy('lastname, firstname', 'asc')->findAll();
$templates = $this->templateModel->getActiveTemplates();
return view('communications/index', compact('students','templates'));
}
// AJAX: families for a student
public function families(int $studentId)
{
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
$families = $this->fsModel->getFamiliesForStudent($studentId);
return $this->response->setJSON(['data' => $families]);
}
// AJAX: guardians for a family
public function guardians(int $familyId)
{
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
$db = \Config\Database::connect();
$rows = $db->query("SELECT u.id as user_id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ?", [$familyId])->getResultArray();
return $this->response->setJSON(['data' => $rows]);
}
public function preview()
{
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
$templateKey = (string) $this->request->getPost('template_key');
$studentId = (int) $this->request->getPost('student_id');
$familyId = (int) $this->request->getPost('family_id');
$varsPost = $this->request->getPost('vars');
$vars = is_array($varsPost) ? $varsPost : json_decode((string)$varsPost, true) ?? [];
$template = $this->templateModel->findByKey($templateKey);
if (!$template) return $this->response->setStatusCode(404)->setJSON(['error' => 'Template not found']);
$student = $this->studentModel->getStudentBasic($studentId);
if (!$student) return $this->response->setStatusCode(404)->setJSON(['error' => 'Student not found']);
// Build salutation from guardians in the chosen family
$db = \Config\Database::connect();
$gs = $db->query("SELECT u.firstname, u.lastname\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ? AND fg.receive_emails = 1\n ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$familyId])->getResultArray();
$sal = 'Parent/Guardian';
if ($gs) {
$names = array_map(fn($r)=> trim(($r['firstname']??'').' '.($r['lastname']??'')), $gs);
$sal = implode(' & ', $names);
}
$autoVars = [
'student_fullname' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
'student_grade' => $student['grade'] ?? '',
'parent_salutation'=> $sal,
'date' => local_date(utc_now(), 'Y-m-d'),
'school_name' => 'Al Rahma Sunday School',
'teacher_name' => (string)(session('display_name') ?? 'Teacher'),
];
$all = array_merge($autoVars, $vars);
$subject = $this->renderTwig($template['subject'], $all);
$body = nl2br($this->renderTwig($template['body'], $all));
return $this->response->setJSON(['subject' => $subject, 'html' => $body]);
}
public function send()
{
$rules = [
'student_id' => 'required|integer',
'family_id' => 'required|integer',
'template_key' => 'required|string',
'subject' => 'required|string',
'body' => 'required|string',
'recipients' => 'required|string' // JSON array
];
if (!$this->validate($rules)) {
return redirect()->back()->with('error', 'Invalid form submission.');
}
$studentId = (int) $this->request->getPost('student_id');
$familyId = (int) $this->request->getPost('family_id');
$templateKey = (string) $this->request->getPost('template_key');
$subject = (string) $this->request->getPost('subject');
$bodyHtml = (string) $this->request->getPost('body');
$recipients = json_decode((string)$this->request->getPost('recipients'), true) ?? [];
$cc = json_decode((string)($this->request->getPost('cc') ?? '[]'), true) ?? [];
$bcc = json_decode((string)($this->request->getPost('bcc') ?? '[]'), true) ?? [];
$student = $this->studentModel->getStudentBasic($studentId);
if (!$student) return redirect()->back()->with('error', 'Student not found');
// Send email (PHPMailer via service('mailer'))
$sendOk = false; $error = null;
try {
$mailer = service('mailer');
foreach (array_unique($recipients) as $to) { if ($to) $mailer->addAddress($to); }
foreach (array_unique($cc) as $c) { if ($c) $mailer->addCC($c); }
foreach (array_unique($bcc) as $b){ if ($b) $mailer->addBCC($b); }
$mailer->Subject = $subject;
$mailer->isHTML(true);
$mailer->Body = $bodyHtml;
$mailer->AltBody = strip_tags($bodyHtml);
$sendOk = $mailer->send();
} catch (\Throwable $t) {
$error = $t->getMessage();
}
// Log
$this->logModel->insert([
'student_id' => $studentId,
'family_id' => $familyId,
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
'template_key' => $templateKey,
'subject' => $subject,
'body' => $bodyHtml,
'recipients' => json_encode(array_values($recipients)),
'cc' => json_encode(array_values($cc)),
'bcc' => json_encode(array_values($bcc)),
'status' => $sendOk ? 'sent' : 'failed',
'error_message'=> $error,
'sent_by' => (int)(session('user_id') ?? 0),
'metadata' => null,
]);
return $sendOk
? redirect()->to('/communications')->with('success', 'Email sent successfully.')
: redirect()->back()->with('error', 'Failed to send email: '.($error ?? 'Unknown error'));
}
private function renderTwig(string $template, array $vars): string
{
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function($m) use ($vars) {
$key = $m[1];
return htmlspecialchars((string)($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
}, $template);
}
}
@@ -0,0 +1,441 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\CompetitionClassWinnerModel;
use App\Models\CompetitionModel;
use App\Models\CompetitionScoreModel;
use App\Models\ConfigurationModel;
use App\Models\TeacherClassModel;
class CompetitionScoresController extends BaseController
{
protected $db;
protected TeacherClassModel $teacherClassModel;
protected ConfigurationModel $configModel;
protected ClassSectionModel $classSectionModel;
protected CompetitionClassWinnerModel $classWinnerModel;
protected string $classStudentTable;
protected bool $hasQuestionCount = false;
protected bool $hasClassWinnerTable = false;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->teacherClassModel = new TeacherClassModel();
$this->configModel = new ConfigurationModel();
$this->classSectionModel = new ClassSectionModel();
$this->classWinnerModel = new CompetitionClassWinnerModel();
if ($this->db->tableExists('class_student')) {
$this->classStudentTable = 'class_student';
} elseif ($this->db->tableExists('student_class')) {
$this->classStudentTable = 'student_class';
} else {
$this->classStudentTable = '';
}
$this->hasClassWinnerTable = $this->db->tableExists('competition_class_winners');
$this->hasQuestionCount = $this->hasClassWinnerTable
&& $this->db->fieldExists('question_count', 'competition_class_winners');
}
public function index()
{
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
$activeClassId = $this->resolveActiveClassId($assignments);
$activeClassName = $this->getActiveClassName($assignments, $activeClassId);
if (empty($assignments) || $activeClassId <= 0) {
return view('teacher/competition_scores/index', [
'competitions' => [],
'activeClassId' => $activeClassId,
'activeClassName' => $activeClassName,
'questionCounts' => [],
'scoreCounts' => [],
'studentTotal' => 0,
'sectionMap' => $this->getClassSectionMap(),
'schoolYear' => $schoolYear,
'semester' => $semester,
'hasClasses' => false,
]);
}
$competitions = $this->getCompetitionsForClass($activeClassId, $schoolYear, $semester);
$competitionIds = array_values(array_filter(array_map(static function ($row) {
return (int) ($row['id'] ?? 0);
}, $competitions)));
$questionCounts = $this->getQuestionCounts($competitionIds, $activeClassId);
$scoreCounts = $this->getScoreCounts($competitionIds, $activeClassId);
$studentTotal = $this->getClassStudentCount($activeClassId, $schoolYear);
return view('teacher/competition_scores/index', [
'competitions' => $competitions,
'activeClassId' => $activeClassId,
'activeClassName' => $activeClassName,
'questionCounts' => $questionCounts,
'scoreCounts' => $scoreCounts,
'studentTotal' => $studentTotal,
'sectionMap' => $this->getClassSectionMap(),
'schoolYear' => $schoolYear,
'semester' => $semester,
'hasClasses' => true,
]);
}
public function edit($id)
{
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
$allowedClassIds = $this->getAllowedClassIds($assignments);
if (empty($allowedClassIds)) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'No class assignments found for your account.');
}
$competitionModel = new CompetitionModel();
$scoreModel = new CompetitionScoreModel();
$competition = $competitionModel->find($id);
if (!$competition) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'Competition not found.');
}
$isLocked = !empty($competition['is_locked']);
$activeClassId = $this->resolveActiveClassId($assignments);
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
$classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId;
if ($classSectionId <= 0) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'Select a class section before entering scores.');
}
if (!in_array($classSectionId, $allowedClassIds, true)) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'You are not assigned to that class.');
}
$students = $this->getStudentsForCompetition($competition, $classSectionId);
$existingScores = $scoreModel
->where('competition_id', $id)
->where('class_section_id', $classSectionId)
->findAll();
$scoreMap = [];
foreach ($existingScores as $row) {
$scoreMap[$row['student_id']] = $row['score'];
}
$questionCount = null;
if ($this->hasQuestionCount && $this->hasClassWinnerTable) {
$row = $this->classWinnerModel
->select('question_count')
->where('competition_id', $id)
->where('class_section_id', $classSectionId)
->first();
if ($row && $row['question_count'] !== null) {
$questionCount = (int) $row['question_count'];
}
}
$classStudentCount = $this->getClassStudentCount(
$classSectionId,
$competition['school_year'] ?? $schoolYear
);
$activeClassName = $this->getActiveClassName($assignments, $classSectionId);
return view('teacher/competition_scores/scores', [
'competition' => $competition,
'students' => $students,
'scoreMap' => $scoreMap,
'classSectionId' => $classSectionId,
'classSectionName' => $activeClassName,
'classStudentCount' => $classStudentCount,
'questionCount' => $questionCount,
'classSelectionLocked' => $lockedClassId > 0,
'isLocked' => $isLocked,
]);
}
public function save($id)
{
[$assignments] = $this->getTeacherContext();
$allowedClassIds = $this->getAllowedClassIds($assignments);
if (empty($allowedClassIds)) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'No class assignments found for your account.');
}
$competitionModel = new CompetitionModel();
$scoreModel = new CompetitionScoreModel();
$competition = $competitionModel->find($id);
if (!$competition) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'Competition not found.');
}
if (!empty($competition['is_locked'])) {
return redirect()->to("/teacher/competition-scores/{$id}")
->with('error', 'Competition is locked. You cannot update scores.');
}
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
$classSectionId = $lockedClassId > 0
? $lockedClassId
: (int) $this->request->getPost('class_section_id');
if ($classSectionId <= 0) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'Select a class section before saving scores.');
}
if (!in_array($classSectionId, $allowedClassIds, true)) {
return redirect()->to('/teacher/competition-scores')
->with('error', 'You are not assigned to that class.');
}
$scores = (array) $this->request->getPost('scores');
$cleanScores = [];
$invalidScores = [];
foreach ($scores as $studentId => $scoreValue) {
$scoreValue = trim((string) $scoreValue);
if ($scoreValue === '') {
continue;
}
if (!preg_match('/^\d+$/', $scoreValue)) {
$invalidScores[] = $studentId;
continue;
}
$cleanScores[(int) $studentId] = (int) $scoreValue;
}
if (!empty($invalidScores)) {
return redirect()->to("/teacher/competition-scores/{$id}")
->with('error', 'Scores must be whole numbers (no decimals).');
}
foreach ($cleanScores as $studentId => $scoreValue) {
$existing = $scoreModel
->where('competition_id', $id)
->where('student_id', (int) $studentId)
->where('class_section_id', $classSectionId)
->first();
if ($existing) {
$scoreModel->update($existing['id'], [
'score' => $scoreValue,
'class_section_id' => $classSectionId,
]);
} else {
$scoreModel->insert([
'competition_id' => (int) $id,
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'score' => $scoreValue,
]);
}
}
return redirect()->to("/teacher/competition-scores/{$id}")
->with('success', 'Scores saved.');
}
private function getTeacherContext(): array
{
$userId = (int) (session()->get('user_id') ?? 0);
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
$userId,
$schoolYear,
$semester
);
return [$assignments, $schoolYear, $semester];
}
private function getAllowedClassIds(array $assignments): array
{
$ids = array_map(static function ($row) {
return (int) ($row['class_section_id'] ?? 0);
}, $assignments);
$ids = array_values(array_filter(array_unique($ids)));
return $ids;
}
private function resolveActiveClassId(array $assignments): int
{
$allowed = $this->getAllowedClassIds($assignments);
$active = (int) (session()->get('class_section_id') ?? 0);
if ($active > 0 && in_array($active, $allowed, true)) {
return $active;
}
$active = $allowed[0] ?? 0;
if ($active > 0) {
session()->set('class_section_id', $active);
}
return (int) $active;
}
private function getActiveClassName(array $assignments, int $classSectionId): ?string
{
if ($classSectionId <= 0) {
return null;
}
foreach ($assignments as $row) {
if ((int) ($row['class_section_id'] ?? 0) === $classSectionId) {
return $row['class_section_name'] ?? null;
}
}
$row = $this->classSectionModel
->select('class_section_name')
->where('class_section_id', $classSectionId)
->first();
return $row['class_section_name'] ?? null;
}
private function getCompetitionsForClass(int $classSectionId, string $schoolYear, string $semester): array
{
$competitionModel = new CompetitionModel();
$builder = $competitionModel->orderBy('id', 'DESC');
if ($classSectionId > 0) {
$builder->groupStart()
->where('class_section_id', $classSectionId)
->orWhere('class_section_id', 0)
->orWhere('class_section_id IS NULL', null, false)
->groupEnd();
}
if ($schoolYear !== '') {
$builder->groupStart()
->where('school_year', $schoolYear)
->orWhere('school_year', '')
->orWhere('school_year IS NULL', null, false)
->groupEnd();
}
if ($semester !== '') {
$builder->groupStart()
->where('semester', $semester)
->orWhere('semester', '')
->orWhere('semester IS NULL', null, false)
->groupEnd();
}
return $builder->findAll();
}
private function getQuestionCounts(array $competitionIds, int $classSectionId): array
{
if (!$this->hasQuestionCount || !$this->hasClassWinnerTable || empty($competitionIds)) {
return [];
}
$rows = $this->classWinnerModel
->select('competition_id, question_count')
->where('class_section_id', $classSectionId)
->whereIn('competition_id', $competitionIds)
->findAll();
$out = [];
foreach ($rows as $row) {
$compId = (int) ($row['competition_id'] ?? 0);
if ($compId > 0 && $row['question_count'] !== null) {
$out[$compId] = (int) $row['question_count'];
}
}
return $out;
}
private function getScoreCounts(array $competitionIds, int $classSectionId): array
{
if (empty($competitionIds)) {
return [];
}
$rows = $this->db->table('competition_scores')
->select('competition_id, COUNT(*) AS total')
->where('class_section_id', $classSectionId)
->whereIn('competition_id', $competitionIds)
->groupBy('competition_id')
->get()
->getResultArray();
$out = [];
foreach ($rows as $row) {
$compId = (int) ($row['competition_id'] ?? 0);
if ($compId > 0) {
$out[$compId] = (int) ($row['total'] ?? 0);
}
}
return $out;
}
private function getClassStudentCount(int $classSectionId, ?string $schoolYear): int
{
if ($classSectionId <= 0 || $this->classStudentTable === '') {
return 0;
}
$builder = $this->db->table($this->classStudentTable)
->select('COUNT(*) AS total')
->where('class_section_id', $classSectionId);
if ($schoolYear && $this->db->fieldExists('school_year', $this->classStudentTable)) {
$builder->where('school_year', $schoolYear);
}
$row = $builder->get()->getRowArray();
return (int) ($row['total'] ?? 0);
}
private function getStudentsForCompetition(array $competition, int $classSectionId): array
{
if ($classSectionId <= 0 || $this->classStudentTable === '') {
return [];
}
$builder = $this->db->table('students s')
->select('s.id, s.school_id, s.firstname, s.lastname')
->join($this->classStudentTable . ' cs', 'cs.student_id = s.id', 'inner')
->where('cs.class_section_id', $classSectionId);
$hasSchoolYear = $this->db->fieldExists('school_year', $this->classStudentTable);
if ($hasSchoolYear && !empty($competition['school_year'])) {
$builder->where('cs.school_year', $competition['school_year']);
}
$builder->distinct();
$builder->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC');
return $builder->get()->getResultArray();
}
private function getClassSectionMap(): array
{
$sections = $this->classSectionModel
->select('class_section_id, class_section_name')
->orderBy('class_section_name', 'ASC')
->findAll();
$map = [];
foreach ($sections as $section) {
$id = (int) ($section['class_section_id'] ?? 0);
if ($id > 0) {
$map[$id] = $section['class_section_name'] ?? (string) $id;
}
}
return $map;
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Controllers\View;
use App\Models\ConfigurationModel;
use CodeIgniter\Controller;
class ConfigurationController extends Controller
{
protected $configModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
}
// Method to load configuration management page
public function index()
{
helper('url');
return view('configuration/configuration_view', [
'configEndpoint' => site_url('api/configuration'),
]);
}
public function addConfig()
{
// Retrieve POST data
$configKey = $this->request->getPost('config_key');
$configValue = $this->request->getPost('config_value');
// Validate inputs (optional)
if ($configKey && $configValue) {
// Save to the database
$this->configModel->save([
'config_key' => $configKey,
'config_value' => $configValue,
]);
// Redirect to the configuration view page
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration added.');
}
// If validation fails, reload the form with input
return redirect()->back()->withInput()->with('error', 'Please fill in all required fields.');
}
// Method to edit an existing configuration
public function editConfig($id)
{
$config = $this->configModel->find($id);
if (strtolower($this->request->getMethod()) === 'post') {
$key = trim((string) $this->request->getPost('config_key'));
$value = trim((string) $this->request->getPost('config_value'));
$this->configModel->update($id, [
'config_key' => $key,
'config_value' => $value
]);
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration updated.');
}
return view('configuration/configuration_edit', ['config' => $config]);
}
public function deleteConfig($id)
{
if ($this->configModel->find($id)) {
$this->configModel->delete($id);
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration deleted successfully.');
}
return redirect()->to('/configuration/configuration_view')->with('error', 'Configuration not found.');
}
public function listData()
{
$rows = $this->configModel
->orderBy('id', 'ASC')
->findAll();
$configs = array_map(static function ($row) {
if (!is_array($row)) {
return [];
}
return [
'id' => (int) ($row['id'] ?? 0),
'config_key' => (string) ($row['config_key'] ?? ''),
'config_value' => (string) ($row['config_value'] ?? ''),
];
}, $rows ?? []);
return $this->response->setJSON(['configs' => $configs]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
class ContactController extends BaseController
{
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
{
helper('form'); // Load the form helper
}
public function index()
{
return view('/parent/contact');
}
public function submit()
{
helper('form'); // Ensure the form helper is loaded
$validation = \Config\Services::validation();
// Define validation rules
$validation->setRules([
'name' => 'required|min_length[3]',
'email' => 'required|valid_email',
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]'
]);
if (!$validation->withRequest($this->request)->run()) {
return view('/parent/contact', [
'validation' => $validation
]);
}
// Process form data
$name = $this->request->getPost('name');
$email = strtolower($this->request->getPost('email'));
$subject = $this->request->getPost('subject');
$message = $this->request->getPost('message');
// Initialize the EmailController
$emailController = new \App\Controllers\View\EmailController();
// Prepare the message to send
$formattedMessage = "
<p><strong>From:</strong> {$name} ({$email})</p>
<p><strong>Subject:</strong> {$subject}</p>
<p>{$message}</p>
";
// Send the email using EmailController
if ($emailController->sendEmail('support@alrahmaisgl.org', $subject, $formattedMessage)) {
$session = session();
$session->setFlashdata('success', 'Thank you for contacting us! We will get back to you soon.');
} else {
$session = session();
$session->setFlashdata('error', 'There was an error sending your message. Please try again later.');
}
return redirect()->to('/parent/contact');
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use App\Models\RoleModel;
class DashboardRedirectController extends Controller
{
public function dashboard()
{
$session = session();
$roles = $session->get('roles') ?? [];
// Fallback: if only a single role is stored
if (empty($roles) && $session->get('role')) {
$roles = [$session->get('role')];
}
// Use your existing method
return $this->redirectToDashboard($roles);
}
// Your existing function (unchanged)
private function redirectToDashboard(array $roles)
{
if (empty($roles)) {
log_message('error', 'Empty roles array passed to redirectToDashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
$roleModel = new RoleModel();
// Resolve all candidate roles (by name or slug), ordered by priority ASC
$rows = $roleModel->findByNamesOrSlugs($roles);
if (!empty($rows)) {
$route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
log_message('debug', 'Redirecting user to: ' . $route);
return redirect()->to($route);
}
log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
return redirect()->to('/landing_page/guest_dashboard');
}
}
+906
View File
@@ -0,0 +1,906 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\DiscountVoucherModel;
use App\Models\DiscountUsageModel;
use App\Models\InvoiceModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\PaymentModel;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\AdditionalChargeModel;
use App\Models\ClassSectionModel;
use CodeIgniter\Events\Events;
class DiscountController extends BaseController
{
protected $configModel;
protected $voucherModel;
protected $invoiceModel;
protected $userModel;
protected $db;
protected $schoolYear;
protected $semester;
protected $paymentModel;
protected $enrollmentModel;
protected $eventChargesModel;
protected $additionalChargeModel;
protected $classSectionModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
$this->voucherModel = new DiscountVoucherModel();
$this->invoiceModel = new InvoiceModel();
$this->userModel = new UserModel();
$this->db = \Config\Database::connect();
$this->paymentModel = new PaymentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->eventChargesModel = new EventChargesModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->classSectionModel = new ClassSectionModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
}
public function applyVoucher()
{
if (strtolower($this->request->getMethod()) === 'post') {
$voucherId = $this->request->getPost('voucher_id');
$parentIds = $this->request->getPost('parent_ids') ?? [];
$allowAdditional = (bool) $this->request->getPost('allow_additional');
if (empty($parentIds)) {
return redirect()->back()->with('error', 'Please select at least one parent.');
}
$voucher = $this->voucherModel->find($voucherId);
if (!$voucher) {
return redirect()->back()->with('error', 'Voucher not found.');
}
// Normalize description from voucher (optional)
$voucherDescription = trim((string) ($voucher['description'] ?? ''));
$voucherDescription = preg_replace(
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
'',
$voucherDescription
);
$maxUsesRaw = $voucher['max_uses'] ?? null;
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
$timesUsed = (int) ($voucher['times_used'] ?? 0);
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
if ($remainingUses <= 0) {
return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.');
}
// If not allowing additional discounts, filter out parents who already have discounts this school year.
if (!$allowAdditional) {
$rows = $this->db->table('discount_usages du')
->select('i.parent_id')
->join('invoices i', 'i.id = du.invoice_id')
->whereIn('i.parent_id', $parentIds)
->where('i.school_year', $this->schoolYear)
->groupBy('i.parent_id')
->get()
->getResultArray();
$blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows);
if (!empty($blockedParentIds)) {
$parentIds = array_values(array_diff(
array_map('intval', $parentIds),
$blockedParentIds
));
}
if (empty($parentIds)) {
return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.');
}
}
$paymentDate = utc_now();
// Collect invoice IDs that end up fully covered by the voucher in this run
$fullyCoveredInvoiceIds = [];
$touchedInvoiceIds = [];
$appliedCount = 0;
$this->db->transStart();
foreach ($parentIds as $parentId) {
// Fetch invoices for this parent & school year
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
if (empty($invoices)) {
// Do NOT early-return inside a transaction; just continue
session()->setFlashdata('error', 'A selected parent has no invoice in record.');
continue;
}
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) break 2; // out of parentIds loop too
// Snapshot current balance BEFORE applying
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
if ($initialPreBalance <= 0) {
log_message(
'error',
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
[
'vid' => (int)$voucherId,
'pid' => (int)$parentId,
'iid' => (int)$invoice['id'],
'inum' => (string)($invoice['invoice_number'] ?? ''),
'bal' => $initialPreBalance,
]
);
continue;
}
// Already used this voucher on this invoice?
if (!$allowAdditional) {
$exists = $this->db->table('discount_usages du')
->join('invoices i', 'du.invoice_id = i.id')
->where('du.voucher_id', $voucherId)
->where('du.invoice_id', $invoice['id'])
->where('i.school_year', $this->schoolYear)
->countAllResults();
if ($exists) {
log_message(
'error',
'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}',
[
'vid' => (int)$voucherId,
'pid' => (int)$parentId,
'iid' => (int)$invoice['id'],
'inum' => (string)($invoice['invoice_number'] ?? ''),
]
);
continue;
}
}
// Calculate discount
$rawDiscount = ($voucher['discount_type'] === 'percent')
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
: (float) $voucher['discount_value'];
// Cap by CURRENT invoice balance snapshot
$discount = min($rawDiscount, $initialPreBalance);
// Nothing to do if no discount
if ($discount <= 0) {
log_message(
'error',
'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}',
[
'vid' => (int)$voucherId,
'pid' => (int)$parentId,
'iid' => (int)$invoice['id'],
'inum' => (string)($invoice['invoice_number'] ?? ''),
'raw' => $rawDiscount,
'bal' => $initialPreBalance,
]
);
continue;
}
// Insert discount usage
$now = utc_now();
$this->db->table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoice['id'],
'parent_id' => $parentId,
'discount_amount' => $discount,
'description' => $voucherDescription,
'school_year' => $this->schoolYear,
'updated_by' => session()->get('user_id'),
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
$this->db->table('invoices')
->where('id', $invoice['id'])
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) $postBalance = 0.0;
// (Optional) current balance re-check (in case of concurrent writes)
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
// Increment voucher usage
$this->db->table('discount_vouchers')
->where('id', $voucherId)
->set('times_used', 'COALESCE(times_used,0) + 1', false)
->update();
// Prepare and trigger payment event
[$eventData, $studentData] = $this->buildPaymentEventData(
$invoice['id'],
$voucherId,
$discount,
'discount',
$paymentDate,
'',
0,
$initialPreBalance, // pre-payment snapshot
$postBalance // computed post-payment
);
Events::trigger('paymentReceived', $eventData, $studentData);
$touchedInvoiceIds[] = (int) $invoice['id'];
$appliedCount++;
// If voucher covered the entire current balance, mark to update enrollments
// Use a small epsilon to tolerate cents rounding.
$epsilon = 0.01;
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
}
$remainingUses--;
// Deactivate if we just hit the cap
if ($remainingUses <= 0 && $maxUses !== null) {
$this->db->table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
}
if ($appliedCount === 0) {
return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.');
}
// ✅ Ensure voucher deactivates when max_uses reached (handles edge cases)
if ($maxUses !== null) {
$row = $this->db->table('discount_vouchers')
->select('times_used')
->where('id', $voucherId)
->get()
->getRowArray();
$usedNow = (int) ($row['times_used'] ?? 0);
if ($usedNow >= $maxUses) {
$this->db->table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => utc_now(),
]);
}
}
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
foreach (array_unique($touchedInvoiceIds) as $iid) {
try {
$this->recalculateInvoice($iid, $this->schoolYear);
} catch (\Throwable $e) {
log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [
'iid' => $iid,
'err' => $e->getMessage(),
]);
}
}
// ✅ AFTER COMMIT: update enrollments for fully-covered invoices
// Avoid nested transactions by doing this post-commit.
$updatedTotal = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
try {
$updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid);
} catch (\Throwable $e) {
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
'iid' => $iid,
'err' => $e->getMessage(),
]);
}
}
if ($updatedTotal > 0) {
return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.');
}
return redirect()->back()->with('success', 'Voucher applied successfully.');
}
// GET: load page
$parents = $this->userModel->getParents();
foreach ($parents as &$parent) {
$discount = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_discount')
->where('parent_id', $parent['id'])
->where('school_year', $this->schoolYear)
->get()
->getRowArray();
$parent['total_discount'] = $discount['total_discount'] ?? 0;
$parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0;
}
unset($parent);
return view('discounts/apply_voucher', [
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
'parents' => $parents,
]);
}
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
{
// 1) Fetch invoice
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
return 0;
}
// 2) Payment check (any payment recorded: balance < total)
$total = (float) ($invoice['total_amount'] ?? 0);
$balance = (float) ($invoice['balance'] ?? 0);
if (!($total > 0 && $balance < $total)) {
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = (string) $this->schoolYear;
if ($parentId <= 0 || $schoolYear === '') {
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
return 0;
}
// 3) Try to limit to enrollments actually present on the invoice (optional)
// If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope.
$paidEnrollmentIds = [];
try {
$rows = $this->db->table('invoice_items')
->select('enrollment_id')
->where('invoice_id', $invoiceId)
->where('enrollment_id IS NOT NULL', null, false)
->get()->getResultArray();
foreach ($rows as $r) {
$eid = (int) ($r['enrollment_id'] ?? 0);
if ($eid > 0) $paidEnrollmentIds[] = $eid;
}
$paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds));
} catch (\Throwable $e) {
// No-op: table/column might not exist in your schema
}
$db = $this->db;
$db->transBegin();
try {
// 4) Preview IDs that will be updated (good for debugging “partials”)
$previewBuilder = $db->table('enrollments')
->select('id')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->groupStart()
// tolerant match for 'payment pending' (case/space/nbsp)
->where('enrollment_status', 'payment pending')
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
->groupEnd();
if (!empty($paidEnrollmentIds)) {
$previewBuilder->whereIn('id', $paidEnrollmentIds);
}
$toUpdateIds = array_map(
static fn($r) => (int)$r['id'],
$previewBuilder->get()->getResultArray()
);
if (empty($toUpdateIds)) {
$db->transCommit();
log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [
'p' => $parentId,
'y' => $schoolYear,
's' => 'ALL'
]);
return 0;
}
// 5) Perform update with same filters
$builder = $db->table('enrollments');
$builder->set('enrollment_status', 'enrolled')
// Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false)
->set('enrollment_date', local_date(utc_now(), 'Y-m-d'))
->whereIn('id', $toUpdateIds);
if ($builder->update() === false) {
$err = $db->error();
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
}
$affected = $db->affectedRows();
$db->transCommit();
log_message(
'info',
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
[
'n' => $affected,
'p' => $parentId,
'y' => $schoolYear,
's' => 'ALL',
'ids' => implode(',', $toUpdateIds),
]
);
return $affected;
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
return 0;
}
}
public function listVouchers()
{
$vouchers = $this->voucherModel->findAll();
return view('discounts/list', ['vouchers' => $vouchers]);
}
public function createVoucher()
{
if (strtolower($this->request->getMethod()) === 'post') {
// -------- Gather & normalize inputs --------
$rawCode = (string) $this->request->getPost('code');
// Keep AZ, 09 and dashes; uppercase for consistency
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode)));
$discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed'
$discountValue = (float) ($this->request->getPost('discount_value') ?? 0);
$maxUsesRaw = $this->request->getPost('max_uses');
$maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw);
$validFrom = trim((string) ($this->request->getPost('valid_from') ?? ''));
$validUntil = trim((string) ($this->request->getPost('valid_until') ?? ''));
$isActive = $this->request->getPost('is_active') ? 1 : 0;
// NEW: Description / Reason
$description = trim((string) ($this->request->getPost('description') ?? ''));
// -------- Basic validations (before model rules) --------
$errors = [];
if ($code === '') {
$errors[] = 'Voucher code is required.';
}
if (!in_array($discountType, ['percent', 'fixed'], true)) {
$errors[] = 'Discount type must be "percent" or "fixed".';
}
if ($discountType === 'percent') {
if ($discountValue <= 0 || $discountValue > 100) {
$errors[] = 'Percentage discount must be between 0 and 100.';
}
} else { // fixed
if ($discountValue < 0) {
$errors[] = 'Fixed discount must be 0 or greater.';
}
}
// Dates come from <input type="date"> as YYYY-MM-DD
$validFrom = $validFrom !== '' ? $validFrom : null;
$validUntil = $validUntil !== '' ? $validUntil : null;
// Ensure date format is plausible
$isDate = static function (?string $d): bool {
if ($d === null) return true;
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
};
if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).';
if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).';
if ($validFrom && $validUntil && $validFrom > $validUntil) {
$errors[] = 'Valid Until must be the same as or after Valid From.';
}
// Optional: require a reason when auto-generated codes are used
// if ($description === '' && strlen($code) === 10) {
// $errors[] = 'Please add a description/reason for this voucher.';
// }
if (!empty($errors)) {
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
}
// -------- Prepare payload for model --------
$data = [
'code' => $code,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'max_uses' => $maxUses,
'valid_from' => $validFrom,
'valid_until' => $validUntil,
'is_active' => $isActive,
'description' => $description, // <-- NEW
];
if ($this->voucherModel->save($data)) {
return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.');
}
// Model-level errors (unique code, etc.)
return redirect()->back()
->withInput()
->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors()));
}
return view('discounts/create');
}
public function editVoucher($id)
{
$voucher = $this->voucherModel->find($id);
if (!$voucher) {
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
}
if (strtolower($this->request->getMethod()) === 'post') {
$data = [
'id' => $id,
'code' => $this->request->getPost('code'),
'discount_type' => $this->request->getPost('discount_type'),
'discount_value' => $this->request->getPost('discount_value'),
'max_uses' => $this->request->getPost('max_uses'),
'valid_from' => $this->request->getPost('valid_from') ?: null,
'valid_until' => $this->request->getPost('valid_until') ?: null,
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
];
$this->voucherModel->save($data);
return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully');
}
return view('discounts/edit', ['voucher' => $voucher]);
}
/**
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
*/
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return 0.0;
// Payments (exclude void/refund/failed, honor year)
$qb = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
$table = $this->paymentModel->table;
$hasStatus = $this->db->fieldExists('status', $table);
$hasVoid = $this->db->fieldExists('is_void', $table);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$rowPaid = $qb->first();
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
// Discounts for this invoice in this year
$rowDisc = $this->db->table('discount_usages du')
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->join('invoices i', 'i.id = du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->get()->getRowArray();
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
// Refunds PAID for this invoice in this year
$rowRefund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
$total = (float)($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
/**
* 🔄 Recalculate invoice totals and status based on all payments for current school year
*/
private function recalculateInvoice($invoiceId, $schoolYear): void
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return;
$parentId = (int)($invoice['parent_id'] ?? 0);
if ($parentId <= 0) return;
// ---- Tuition (recompute from enrollments) ----
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$registered = [];
$withdrawn = [];
foreach ($enrollments as $e) {
$row = [
'student_id' => (int)($e['student_id'] ?? 0),
'class_section_id' => $e['class_section_id'] ?? null,
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
];
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
$registered[] = $row;
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
$withdrawn[] = $row;
}
}
// Refund window check if after deadline, withdrawn still billed
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
$refundAllowed = true;
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
$refundAllowed = $today <= $deadline;
} catch (\Throwable $e) {
$refundAllowed = true;
}
$tuitionStudents = $registered;
if (!$refundAllowed) {
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
}
// Grade threshold and fees
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
// Normalize grades for tuition students
foreach ($tuitionStudents as &$s) {
$name = null;
if (!empty($s['class_section_id'])) {
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
}
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
}
unset($s);
// Count regular vs youth and compute tuition
$regularCount = 0;
$youthCount = 0;
foreach ($tuitionStudents as $s) {
$lvl = $this->parseGradeLevel($s['grade_name']);
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
}
$tuitionSubtotal = 0.0;
$tuitionSubtotal += $youthCount * $youthFee;
if ($regularCount >= 2) {
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
} elseif ($regularCount === 1) {
$tuitionSubtotal += $firstStudentFee;
}
// ---- Event charges (parent-year) ----
$eventSubtotal = 0.0;
try {
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
} catch (\Throwable $e) {}
// ---- Additional charges (per-invoice) ----
$additionalSubtotal = 0.0;
try {
$rows = $this->additionalChargeModel
->select('charge_type, amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied')
->findAll();
foreach ($rows as $r) {
$amt = (float)($r['amount'] ?? 0);
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
$additionalSubtotal += $amt;
}
} catch (\Throwable $e) {}
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
// ---- Payments / Discounts / Refunds ----
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$qb = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$payments = $qb->findAll();
$totalPaid = 0.0;
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
$updateData = [
'total_amount' => $newTotal,
'paid_amount' => $totalPaid,
'balance' => $newBalance,
'status' => $newStatus,
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
];
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
$updateData['discount'] = $totalDisc;
}
$this->invoiceModel->update($invoiceId, $updateData);
}
/**
* Parse a grade name into an integer level for tuition rules.
* Kindergarten -> 1, Youth -> > 9, numeric grades passthrough.
*/
private function parseGradeLevel($grade): int
{
if (is_numeric($grade)) return (int)$grade;
if (!is_string($grade)) return 999;
$g = strtoupper(trim((string)$grade));
$g = preg_replace('/\s+/', ' ', $g);
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
// KG/K/Kindergarten
$kg = ['K','KG','K G','KINDER','KINDERGARTEN'];
if (in_array($g, $kg, true)) return 1;
// Pre-K -> treat below regular
$pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER'];
if (in_array($g, $pk, true)) return -1;
// Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) {
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1;
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
return $gradeFee + $n;
}
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
return (int)$m[1];
}
return 999;
}
/**
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
*
* @return array [$data, $studentdata]
*/
private function buildPaymentEventData(
int $invoiceId,
string $transactionIdOrRef,
float $amount,
string $paymentMethod,
string $paymentDate,
?string $checkNumber,
int $installmentSeq,
float $preBalance, // ✅ new param: initial (pre-payment) balance
float $postBalance // ✅ new param: computed post-payment balance
): array {
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
throw new \RuntimeException("Invoice {$invoiceId} not found");
}
$parentRow = $this->db->table('users')
->select('id, firstname, lastname, email')
->where('id', $invoice['parent_id'])
->get()
->getRowArray();
if (!$parentRow) {
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
}
// Students (adjust joins to your schema)
$studentRows = $this->db->table('students s')
->select('s.id, s.firstname, s.lastname, sc.class_section_id')
->join('student_class sc', 'sc.student_id = s.id', 'left')
->where('s.parent_id', $invoice['parent_id'])
->where('sc.school_year', $this->schoolYear)
->get()
->getResultArray();
$data = [
'user_id' => (int) $parentRow['id'],
'email' => $parentRow['email'],
'firstname' => $parentRow['firstname'],
'lastname' => $parentRow['lastname'],
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'invoice_id' => $invoiceId,
'transaction_id' => $transactionIdOrRef,
'payment_date' => $paymentDate,
'amount' => $amount,
'method' => $paymentMethod,
'check_number' => $checkNumber,
'installment_seq' => $installmentSeq,
'invoice_total' => (float) $invoice['total_amount'],
'pre_balance' => $preBalance, // ✅ the captured pre-payment balance
'post_balance' => $postBalance, // ✅ computed post-payment balance
'portalLink' => site_url('parent/invoices/' . $invoiceId),
];
return [$data, $studentRows];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
class DocsController extends BaseController
{
public function swagger()
{
// Present both specs in a Swagger UI dropdown
$specs = [
[
'name' => 'Administrator API',
'url' => '/docs/openapi_administrator_controller.yaml',
],
[
'name' => 'Parent API',
'url' => '/docs/openapi_parent_controller.yaml',
],
];
return view('swagger_ui', ['specs' => $specs]);
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class EmailController extends Controller
{
/**
* Send an email using a named profile (e.g., 'communication', 'payment', 'default').
*
* @param string $recipient
* @param string $subject
* @param string $htmlMessage
* @param string|null $profile e.g. 'communication', 'payment'; if null uses MAIL_PROFILE_DEFAULT or 'default'
* @param string|null $replyToEmail optional override
* @param string|null $replyToName optional override
* @param array $attachments [['path'=>..., 'name'=>...], ...]
*/
public function sendEmail(
string $recipient,
string $subject,
string $htmlMessage,
?string $profile = null,
?string $replyToEmail = null,
?string $replyToName = null,
array $attachments = []
): bool {
// Composer autoload (if not already loaded by CI4)
$autoload = APPPATH . '../vendor/autoload.php';
if (is_file($autoload)) {
require_once $autoload;
}
$profile = $this->resolveProfile($profile);
$cfg = $this->getProfileConfig($profile);
// Guard rails: refuse to proceed with missing essentials
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
return false;
}
// Optional env flags
$debugEnabled = (bool) env('MAIL_DEBUG', false);
$timeout = (int) env('MAIL_TIMEOUT', 15); // seconds
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
$verifyPeer = env('MAIL_VERIFY_PEER', 'true'); // 'true'|'false' (string to allow .env)
$verifyPeer = filter_var($verifyPeer, FILTER_VALIDATE_BOOLEAN);
// Preflight DNS + socket (helps distinguish firewall/DNS vs. auth)
$targetHost = $cfg['host'];
$targetPort = (int) $cfg['port'];
$resolved = @gethostbyname($targetHost);
if (!$resolved || $resolved === $targetHost) {
// Not fatal (some environments block gethostbyname), but useful log
log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
}
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
if (!$sockOk) {
log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host.");
} else {
fclose($sockOk);
}
$mail = new PHPMailer(true);
try {
// Capture PHPMailers own debug if enabled
if ($debugEnabled) {
ob_start();
}
// PHPMailer core setup
$mail->isSMTP();
$mail->Host = $cfg['host'];
$mail->Port = $cfg['port'];
$mail->SMTPAuth = true;
$mail->Username = $cfg['user'];
$mail->Password = $cfg['pass'];
$mail->CharSet = 'UTF-8';
$mail->Timeout = $timeout; // socket timeout
$mail->SMTPKeepAlive = $keepAlive; // reuse connection for multiple sends
$mail->SMTPAutoTLS = true; // allow auto TLS upgrade when possible
// Encryption mapping: 'tls' => STARTTLS (587), 'ssl' => implicit TLS (465)
if ($cfg['encryption'] === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} else {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
}
// TLS verification options (can relax on demand via .env for on-prem/self-signed)
$mail->SMTPOptions = [
'ssl' => [
'verify_peer' => $verifyPeer,
'verify_peer_name' => $verifyPeer,
'allow_self_signed' => !$verifyPeer,
],
];
// Optional verbose debugging (set MAIL_DEBUG=true in .env)
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
$mail->Debugoutput = 'error_log';
// From / Return-Path
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
if (!empty($cfg['returnPath'])) {
$mail->Sender = $cfg['returnPath'];
}
// Configurable Reply-To: env overrides inputs/config; fallback to profile/defaults
$mail->clearReplyTos();
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
$rtName = env('MAIL_DEFAULT_REPLY_TO_NAME');
if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) {
$rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']);
}
if (!$rtName) {
$rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']);
}
$rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']);
if ($rtEmail) {
$mail->addReplyTo($rtEmail, $rtName);
}
// DKIM (optional)
if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) {
$mail->DKIM_domain = $cfg['dkim']['domain'];
$mail->DKIM_private = $cfg['dkim']['private']; // path to private key file
$mail->DKIM_selector = $cfg['dkim']['selector'];
$mail->DKIM_identity = $cfg['fromEmail'];
}
// Recipient(s)
$mail->addAddress($recipient);
// Attachments
foreach ($attachments as $att) {
if (!empty($att['path']) && is_file($att['path'])) {
$mail->addAttachment($att['path'], $att['name'] ?? '');
}
}
// Content
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlMessage;
// Send!
$ok = $mail->send();
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
if ($ok) {
log_message('info', "[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
return true;
}
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
return false;
} catch (Exception $e) {
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
return false;
}
}
/** Resolve null/alias profile names to a canonical env prefix. */
private function resolveProfile(?string $profile): string
{
$p = strtolower(trim((string) $profile));
if ($p === '' || $p === 'auto') {
$p = strtolower((string) getenv('MAIL_PROFILE_DEFAULT')) ?: 'default';
}
return match ($p) {
'comm', 'comms' => 'communication',
'sys', 'system' => 'default',
default => $p, // e.g., 'payment', 'admissions', etc.
};
}
/** Turn 'communication' into 'COMMUNICATION' for env lookups/logs. */
private function envKeyFromProfile(string $profile): string
{
return strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', $profile));
}
/**
* Resolve SMTP & identity for any profile with layered fallbacks:
* 1) MAIL_{PROFILE}_*
* 2) MAIL_DEFAULT_*
* 3) legacy SMTP_* (HOST/USER/PASS/PORT/ENCRYPTION)
* 4) hard defaults
*/
private function getProfileConfig(string $profile): array
{
$key = $this->envKeyFromProfile($profile);
// Helper: first non-empty env from a list
$envFirst = function (array $keys, $default = null) {
foreach ($keys as $k) {
$v = env($k);
if (is_string($v)) $v = trim($v);
if ($v !== null && $v !== '') return $v;
}
return $default;
};
// Core SMTP (env first, then legacy SMTP_*)
$host = $envFirst(["MAIL_{$key}_HOST", "MAIL_DEFAULT_HOST", "SMTP_HOST"], '');
$user = $envFirst(["MAIL_{$key}_USER", "MAIL_DEFAULT_USER", "SMTP_USER"], '');
$pass = $envFirst(["MAIL_{$key}_PASS", "MAIL_DEFAULT_PASS", "SMTP_PASS"], '');
$portRaw = $envFirst(["MAIL_{$key}_PORT", "MAIL_DEFAULT_PORT", "SMTP_PORT"], 587);
$encRaw = $envFirst(["MAIL_{$key}_ENCRYPTION", "MAIL_DEFAULT_ENCRYPTION", "SMTP_ENCRYPTION"], 'tls');
// Fallback to Config\Email when env is not set (common in local/dev)
$cfgEmail = config('Email');
if ($cfgEmail) {
if ($host === '' && !empty($cfgEmail->SMTPHost)) {
$host = $cfgEmail->SMTPHost;
}
if ($user === '' && !empty($cfgEmail->SMTPUser)) {
$user = $cfgEmail->SMTPUser;
}
if ($pass === '' && !empty($cfgEmail->SMTPPass)) {
$pass = $cfgEmail->SMTPPass;
}
if ((int) $portRaw <= 0 && !empty($cfgEmail->SMTPPort)) {
$portRaw = $cfgEmail->SMTPPort;
}
if (($encRaw === '' || $encRaw === 'tls') && !empty($cfgEmail->SMTPCrypto)) {
$encRaw = $cfgEmail->SMTPCrypto;
}
}
// Identity
$fromEmail = $envFirst(["MAIL_{$key}_FROM_EMAIL", "MAIL_DEFAULT_FROM_EMAIL"], $user ?: 'no-reply@alrahmaisgl.org');
$fromName = $envFirst(["MAIL_{$key}_FROM_NAME", "MAIL_DEFAULT_FROM_NAME"], 'Al Rahma Sunday School');
$replyTo = $envFirst(["MAIL_{$key}_REPLY_TO", "MAIL_DEFAULT_REPLY_TO"], '');
$replyToNm = $envFirst(["MAIL_{$key}_REPLY_TO_NAME","MAIL_DEFAULT_REPLY_TO_NAME"], '');
$returnPath = $envFirst(["MAIL_{$key}_RETURN_PATH","MAIL_DEFAULT_RETURN_PATH"], '');
// DKIM (optional)
$dkim = [
'domain' => $envFirst(["MAIL_{$key}_DKIM_DOMAIN", "MAIL_DEFAULT_DKIM_DOMAIN"], ''),
'selector' => $envFirst(["MAIL_{$key}_DKIM_SELECTOR", "MAIL_DEFAULT_DKIM_SELECTOR"], ''),
'private' => $envFirst(["MAIL_{$key}_DKIM_PRIVATE", "MAIL_DEFAULT_DKIM_PRIVATE"], ''), // full path
];
// Normalize types/values
$port = (int) $portRaw ?: 587;
$encryption = strtolower((string) $encRaw);
$encryption = in_array($encryption, ['tls','ssl'], true) ? $encryption : 'tls';
// --- Autocorrect common mistakes (prevents silent handshake failures) ---
if ($port === 587 && $encryption === 'ssl') {
$encryption = 'tls';
}
if ($port === 465 && $encryption === 'tls') {
$encryption = 'ssl';
}
return [
'host' => (string) $host,
'user' => (string) $user,
'pass' => (string) $pass,
'port' => $port,
// map used above: 'tls' => STARTTLS, 'ssl' => SMTPS
'encryption' => $encryption,
'fromEmail' => (string) $fromEmail,
'fromName' => (string) $fromName,
'replyTo' => (string) $replyTo,
'replyToName' => (string) $this->sanitizeReplyToName($replyToNm, (string) $fromName),
'returnPath' => (string) $returnPath,
'dkim' => $dkim,
];
}
/**
* Normalize Reply-To display names, removing "No-Reply/No-Replay" placeholders.
*/
private function sanitizeReplyToName(?string $name, string $fallback): string
{
$trimmed = trim((string) $name);
if ($trimmed === '' || preg_match('/^no[- ]?repl(?:y|ay)$/i', $trimmed)) {
return $fallback;
}
return $trimmed;
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Controllers\View;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Controller;
use CodeIgniter\Database\Exceptions\DatabaseException;
class EmailExtractorController extends Controller
{
/**
* GET /email-extractor
* Renders the frontend page (view) with CSV upload and comparison UI.
*/
public function index()
{
return view('/emails/parent_email_extractor');
}
/**
* GET /api/emails
* Returns JSON: { users: string[], parents: string[] }
* Pulls emails from users.email and parents.secondparent_email
*/
public function getEmails()
{
$db = db_connect();
$users = [];
$parents = [];
try {
// Fetch users.email (non-null, non-empty)
$builderUsers = $db->table('users')->select('email');
$userRows = $builderUsers->get()->getResultArray();
foreach ($userRows as $row) {
$email = strtolower(trim((string)($row['email'] ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$users[] = $email;
}
}
// De-duplicate
$users = array_values(array_unique($users));
// Fetch parents.secondparent_email (non-null, non-empty)
$builderParents = $db->table('parents')->select('secondparent_email');
$parentRows = $builderParents->get()->getResultArray();
foreach ($parentRows as $row) {
$email = strtolower(trim((string)($row['secondparent_email'] ?? '')));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$parents[] = $email;
}
}
// De-duplicate
$parents = array_values(array_unique($parents));
return $this->response->setJSON([
'users' => $users,
'parents' => $parents,
])->setStatusCode(ResponseInterface::HTTP_OK);
} catch (DatabaseException $e) {
return $this->response->setJSON([
'error' => 'Database error: ' . $e->getMessage(),
])->setStatusCode(ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* POST /api/compare
* Accepts multipart/form-data with a CSV file named 'file' (optional),
* or JSON body with { csvEmails: string[] } (optional).
* Compares against DB and returns:
* {
* existed: string[], // in CSV AND in DB
* needToAdd: string[], // in DB BUT NOT in CSV
* counts: { csv: number, db: number, users: number, parents: number }
* }
*/
public function compare()
{
$request = $this->request;
$csvEmails = [];
// 1) Try read from uploaded file
$file = $request->getFile('file');
if ($file && $file->isValid()) {
$contents = file_get_contents($file->getTempName());
$csvEmails = $this->extractEmailsFromText($contents);
}
// 2) Or from JSON body
if (empty($csvEmails) && $request->getHeaderLine('Content-Type')) {
$contentType = $request->getHeaderLine('Content-Type');
if (stripos($contentType, 'application/json') !== false) {
$json = $request->getJSON(true);
if (isset($json['csvEmails']) && is_array($json['csvEmails'])) {
$csvEmails = $this->normalizeEmailArray($json['csvEmails']);
}
}
}
// 3) Compare with DB
$db = db_connect();
// Fetch DB emails
$users = [];
$parents = [];
$userRows = $db->table('users')->select('email')->get()->getResultArray();
foreach ($userRows as $row) {
$e = strtolower(trim((string)($row['email'] ?? '')));
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
$users[] = $e;
}
}
$users = array_values(array_unique($users));
$parentRows = $db->table('parents')->select('secondparent_email')->get()->getResultArray();
foreach ($parentRows as $row) {
$e = strtolower(trim((string)($row['secondparent_email'] ?? '')));
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
$parents[] = $e;
}
}
$parents = array_values(array_unique($parents));
// Sets for comparison
$csvSet = array_flip(array_values(array_unique($csvEmails)));
$dbUnion = array_values(array_unique(array_merge($users, $parents)));
$dbSet = array_flip($dbUnion);
// existed: in CSV and in DB
$existed = [];
foreach ($csvSet as $email => $_) {
if (isset($dbSet[$email])) {
$existed[] = $email;
}
}
sort($existed);
// needToAdd: in DB but NOT in CSV
$needToAdd = [];
foreach ($dbSet as $email => $_) {
if (!isset($csvSet[$email])) {
$needToAdd[] = $email;
}
}
sort($needToAdd);
return $this->response->setJSON([
'existed' => $existed,
'needToAdd' => $needToAdd,
'counts' => [
'csv' => count($csvEmails),
'db' => count($dbUnion),
'users' => count($users),
'parents' => count($parents),
],
]);
}
// Helpers
private function normalizeEmailArray(array $arr): array
{
$out = [];
foreach ($arr as $e) {
$email = strtolower(trim((string)$e));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$out[] = $email;
}
}
return array_values(array_unique($out));
}
private function extractEmailsFromText(string $text): array
{
$pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i';
preg_match_all($pattern, $text, $matches);
$emails = $matches[0] ?? [];
return $this->normalizeEmailArray($emails);
}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\EmergencyContactModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use CodeIgniter\HTTP\ResponseInterface;
class EmergencyContactController extends BaseController
{
protected $contactModel;
protected $studentModel;
protected $userModel;
public function __construct()
{
$this->contactModel = new EmergencyContactModel();
$this->studentModel = new StudentModel();
$this->userModel = new UserModel(); // Add this
}
public function index()
{
$db = \Config\Database::connect();
$parentIds = $this->contactModel
->distinct()
->select('parent_id')
->findAll();
$data = [];
foreach ($parentIds as $row) {
$parentId = $row['parent_id'];
$parent = $this->userModel->find($parentId); // Get parent info
$parentName = $parent ? $parent['firstname'] . ' ' . $parent['lastname'] : 'Unknown Parent';
$parentPhone = is_array($parent) ? (string)($parent['cellphone'] ?? '') : '';
// Try to load second parent phone from parents table (if available)
$secondPhone = '';
try {
$row = $db->table('parents')
->select('secondparent_phone')
->where('firstparent_id', (int) $parentId)
->orderBy('updated_at', 'DESC')
->get()->getRowArray();
if ($row && !empty($row['secondparent_phone'])) {
$secondPhone = (string) $row['secondparent_phone'];
}
} catch (\Throwable $e) {
log_message('debug', 'EmergencyContactController: could not load second parent phone: ' . $e->getMessage());
}
$students = $this->studentModel
->where('parent_id', $parentId)
->findAll();
$contacts = $this->contactModel
->getEmergencyContactsByParentId($parentId);
$data[] = [
'parent_id' => $parentId,
'parent_name' => $parentName,
'students' => $students,
'contacts' => $contacts,
'parent_phones' => array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== '')),
];
}
return view('administrator/emergency_contact/index', ['groups' => $data]);
}
public function edit($id)
{
$contact = $this->contactModel->find($id);
return view('administrator/emergency_contact/edit', ['contact' => $contact]);
}
public function update($id)
{
dd("Update was called with ID: $id", $this->request->getPost());
}
public function delete($id)
{
$this->contactModel->delete($id);
return redirect()->to('/administrator/emergency_contact')->with('success', 'Contact deleted.');
}
// API: JSON payload for emergency contacts grouped by parent
public function data()
{
// Build groups similarly to index(), but return JSON
$parentRows = $this->contactModel
->distinct()
->select('parent_id')
->findAll();
$groups = [];
foreach ($parentRows as $row) {
$parentId = (int)($row['parent_id'] ?? 0);
if ($parentId <= 0) continue;
$parent = $this->userModel->find($parentId) ?: [];
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent';
$students = $this->studentModel
->select('id, firstname, lastname, school_id')
->where('parent_id', $parentId)
->findAll();
$contacts = $this->contactModel
->where('parent_id', $parentId)
->orderBy('updated_at', 'DESC')
->findAll();
$groups[] = [
'parent_id' => $parentId,
'parent_name' => $parentName,
'students' => $students,
'contacts' => $contacts,
];
}
return $this->response->setJSON([
'groups' => $groups,
'csrfHash' => csrf_hash(),
]);
}
}
+360
View File
@@ -0,0 +1,360 @@
<?php
namespace App\Controllers\View;
use App\Models\EventModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Controllers\View\InvoiceController;
use CodeIgniter\RESTful\ResourceController;
class EventController extends ResourceController
{
protected $eventChargesModel;
protected $studentModel;
protected $userModel;
protected $configModel;
protected $eventModel;
protected $invoiceController;
protected $schoolYear;
protected $semester;
protected $categories;
protected $enrollmentModel;
public function __construct()
{
$this->eventChargesModel = new EventChargesModel(); //eventChargesModel
$this->studentModel = new StudentModel();
$this->configModel = new ConfigurationModel();
$this->userModel = new UserModel(); // Add this
$this->eventModel = new EventModel();
$this->invoiceController = new InvoiceController();
$this->enrollmentModel = new EnrollmentModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->categories = [
'workshops',
'orientations',
'field trips',
'Ramadan programs',
];
}
public function index()
{
$eventModel = new EventModel();
$today = local_date(utc_now(), 'Y-m-d');
// Fetch all events
$events = $eventModel
->orderBy('created_at', 'DESC')
->findAll();
// Fetch active events (not expired)
$activeEventCount = $eventModel
->where('expiration_date >=', $today)
->countAllResults();
return view('administrator/events/event_list', [
'events' => $events,
'activeEventCount' => $activeEventCount
]);
}
public function create()
{
helper(['form']);
if (strtolower($this->request->getMethod()) === 'post') {
$file = $this->request->getFile('flyer');
$flyerPath = null;
if ($file && $file->isValid() && !$file->hasMoved()) {
// Move to public/uploads/event_flyers
$newName = $file->getRandomName();
$file->move(FCPATH . 'uploads/event_flyers', $newName);
$flyerPath = 'event_flyers/' . $newName; // store relative path
}
$eventId = $this->eventModel->insert([
'event_name' => $this->request->getPost('event_name'),
'event_category' => $this->request->getPost('event_category'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount'),
'flyer' => $flyerPath,
'expiration_date' => $this->request->getPost('expiration_date'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
'created_by' => session()->get('user_id'),
]);
if ($eventId) {
$amount = (float) $this->request->getPost('amount');
$semester = (string) $this->request->getPost('semester');
$schoolYear = (string) $this->request->getPost('school_year');
$userId = (int) (session()->get('user_id') ?? 0);
$enrollments = $this->enrollmentModel
->select('enrollments.student_id, students.parent_id')
->join('students', 'students.id = enrollments.student_id', 'left')
->where('enrollments.school_year', $schoolYear)
->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending'])
->findAll();
$parentIds = [];
foreach ($enrollments as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$parentId = (int) ($row['parent_id'] ?? 0);
if ($studentId <= 0 || $parentId <= 0) {
continue;
}
$exists = $this->eventChargesModel
->where('event_id', $eventId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($exists) {
continue;
}
$this->eventChargesModel->insert([
'event_id' => $eventId,
'parent_id' => $parentId,
'student_id' => $studentId,
'participation' => 'yes',
'charged' => $amount,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $userId ?: null,
]);
$parentIds[] = $parentId;
}
$parentIds = array_unique($parentIds);
foreach ($parentIds as $pid) {
$this->invoiceController->generateInvoice((string) $pid);
}
}
return redirect()->to('/administrator/events')->with('success', 'Event created successfully');
}
return view('administrator/events/create_event', [
'categories' => $this->categories,
]);
}
public function edit($id = null)
{
helper(['form']);
$event = $this->eventModel->find($id);
if (!$event) {
return redirect()->to('/administrator/events')->with('error', 'Event not found');
}
if (strtolower($this->request->getMethod()) === 'post') {
log_message('debug', 'POST detected');
$file = $this->request->getFile('flyer');
$flyerPath = $event['flyer']; // Default: keep old flyer
if ($file && $file->isValid() && !$file->hasMoved()) {
$newName = $file->getRandomName();
$file->move(FCPATH . 'uploads/event_flyers', $newName);
$flyerPath = 'event_flyers/' . $newName; // store relative path
}
$updated = $this->eventModel->update($id, [
'event_name' => $this->request->getPost('event_name'),
'event_category' => $this->request->getPost('event_category'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount'),
'flyer' => $flyerPath,
'expiration_date' => $this->request->getPost('expiration_date'),
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
]);
if ($updated) {
return redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
} else {
log_message('debug', 'GET detected');
return redirect()->back()->with('error', 'Failed to update event');
}
}
return view('administrator/events/edit_event', [
'event' => $event,
'categories' => $this->categories,
]);
}
public function delete($id = null)
{
$event = $this->eventModel->find($id);
if (!$event) {
return redirect()->to('/administrator/events')->with('error', 'Event not found');
}
// Delete related charges and collect parent IDs
$charges = $this->eventChargesModel->where('event_id', $id)->findAll();
$parentIds = [];
foreach ($charges as $charge) {
$this->eventChargesModel->delete($charge['id']);
$parentIds[] = $charge['parent_id'];
}
// Delete event
$this->eventModel->delete($id);
$parentIds = array_unique($parentIds);
foreach ($parentIds as $parentId) {
$this->invoiceController->generateInvoice($parentId);
}
return redirect()->to('/administrator/events')->with('success', 'Event, charges, and invoices updated.');
}
// Optionally keep your eventShow / eventUpdate for legacy administrator event charges
public function eventShow()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($this->schoolYear);
$charges = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester)
->orderBy('event_charges.created_at', 'DESC')
->findAll();
return view('administrator/events/event_charges', [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
]);
}
public function eventUpdate()
{
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
$semester = $this->request->getPost('semester') ?? $this->semester;
$parentId = $this->request->getPost('parent_id');
$eventId = $this->request->getPost('event_id');
$participations = $this->request->getPost('participation') ?? [];
if (!$parentId || !$eventId || empty($participations)) {
return redirect()->back()->with('error', 'Missing required information.');
}
$userId = session()->get('user_id');
$event = $this->eventModel->getEvent($eventId, $schoolYear);
foreach ($participations as $studentId => $value) {
$existing = $this->eventChargesModel->where([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'school_year' => $schoolYear,
'semester' => $semester
])->first();
if ($value === 'no') {
if ($existing) {
$this->eventChargesModel->delete($existing['id']);
}
continue;
}
// value is 'yes'
if ($existing) {
$this->eventChargesModel->update($existing['id'], [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId
]);
} else {
$this->eventChargesModel->insert([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
'updated_by' => $userId
]);
}
}
$this->invoiceController->generateInvoice($parentId);
return redirect()->back()->with('success', 'Event charges updated successfully.');
}
public function getStudentsWithCharges()
{
$parentId = $this->request->getGet('parent_id');
$semester = $this->request->getGet('semester');
$schoolYear = $this->request->getGet('school_year');
// Get students for parent
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
// Get student_ids that already have charges
$chargedStudentIds = $this->eventChargesModel
->where('parent_id', $parentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->groupBy('student_id')
->select('student_id')
->findColumn('student_id');
$data = [];
foreach ($students as $student) {
$data[] = [
'id' => $student['id'],
'name' => $student['firstname'] . ' ' . $student['lastname'],
'charged' => in_array($student['id'], $chargedStudentIds ?? []),
];
}
return $this->response->setJSON($data);
}
}
@@ -0,0 +1,605 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\ExamDraftModel;
use App\Models\TeacherClassModel;
use App\Models\UserModel;
use CodeIgniter\HTTP\Files\UploadedFile;
use Config\Database;
class ExamDraftController extends BaseController
{
protected ExamDraftModel $examDraftModel;
protected TeacherClassModel $teacherClassModel;
protected ClassSectionModel $classSectionModel;
protected UserModel $userModel;
protected ConfigurationModel $configModel;
protected string $schoolYear;
protected string $semester;
protected bool $hasFinalPdfColumn = false;
protected bool $hasIsLegacyColumn = false;
// DB enum: draft, submitted, reviewed, finalized, rejected
// (string literals used to avoid excess constants)
protected const TEACHER_UPLOAD_DIR = 'exams/drafts';
protected const FINAL_UPLOAD_DIR = 'exams/finals';
protected const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
protected const ALLOWED_EXTENSIONS = ['doc', 'docx'];
protected const ADMIN_ALLOWED_EXTENSIONS = ['doc', 'docx', 'pdf'];
protected array $examTypes = [
'Final Exam',
'Midterm Exam',
'Quiz',
'Study Guide',
'Practice Exam',
'Other',
];
public function __construct()
{
$this->examDraftModel = new ExamDraftModel();
$this->teacherClassModel = new TeacherClassModel();
$this->classSectionModel = new ClassSectionModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->db = Database::connect();
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
helper(['form', 'url', 'date']);
}
public function teacherIndex()
{
$teacherId = (int) (session()->get('user_id') ?? 0);
if ($teacherId <= 0) {
return redirect()->to('/login');
}
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear);
$selectedClass = $this->resolveSelectedClassSection($assignments);
if ($selectedClass > 0) {
session()->set('class_section_id', $selectedClass);
}
$allDrafts = $this->examDraftModel
->where('teacher_id', $teacherId)
->orderBy('created_at', 'DESC')
->findAll();
foreach ($allDrafts as &$row) {
if (empty($row['final_pdf_file'])) {
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
if ($pdf !== null) {
$row['final_pdf_file'] = $pdf;
}
}
}
unset($row);
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
$legacyExams = [];
// Guard against missing schema column in older databases
if ($this->hasIsLegacyColumn) {
// Keep all submissions visible; legacy ones are also surfaced in a separate tab
$drafts = $allDrafts;
if (!empty($classSectionIds)) {
$legacyExams = $this->examDraftModel
->select('exam_drafts.*, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->whereIn('exam_drafts.class_section_id', $classSectionIds)
->where('exam_drafts.is_legacy', 1)
->where('exam_drafts.final_file IS NOT NULL', null, false)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
}
} else {
// Legacy column absent, show all drafts and skip legacy tab query
$drafts = $allDrafts;
}
$validation = session()->getFlashdata('validation') ?? $this->validator;
return view('teacher/exam_drafts', [
'assignments' => $assignments,
'selectedClassSection' => $selectedClass,
'drafts' => $drafts,
'legacyExams' => $legacyExams,
'examTypes' => $this->examTypes,
'statusBadges' => $this->statusBadgeMap(),
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
'validation' => $validation,
]);
}
public function teacherStore()
{
$teacherId = (int) (session()->get('user_id') ?? 0);
if ($teacherId <= 0) {
return redirect()->to('/login');
}
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
$examType = trim((string) $this->request->getPost('exam_type'));
$description = trim((string) $this->request->getPost('description'));
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
}
$assignment = $this->teacherClassModel
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->first();
if (empty($assignment)) {
return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.');
}
session()->set('class_section_id', $classSectionId);
if ($classSectionId <= 0) {
$classSectionId = $this->resolveSelectedClassSection($assignments);
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
}
}
$file = $this->request->getFile('draft_file');
$teacherFile = null;
$teacherFilename = null;
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $this->storeUploadedFile($file, self::TEACHER_UPLOAD_DIR);
if ($stored === null) {
return redirect()->back()->withInput()->with('error', 'Failed to store the uploaded file.');
}
$teacherFile = $stored;
$teacherFilename = $file->getClientName();
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
return redirect()->back()->withInput()->with('error', 'Upload failed. Please try again.');
}
$existingDraft = $this->examDraftModel
->where('teacher_id', $teacherId)
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->orderBy('version', 'DESC')
->first();
$nextVersion = 1;
$previousId = null;
if (!empty($existingDraft)) {
$nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1;
$previousId = (int) ($existingDraft['id'] ?? 0) ?: null;
}
$title = $examType ?: 'Exam Draft';
$payload = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'exam_type' => $examType ?: null,
'draft_title' => $title,
'description' => $description === '' ? null : $description,
'teacher_file' => $teacherFile,
'teacher_filename' => $teacherFilename,
'status' => 'submitted',
'version' => $nextVersion,
'previous_draft_id' => $previousId,
];
if ($this->examDraftModel->insert($payload)) {
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the exam draft.');
}
public function adminIndex()
{
$allDrafts = $this->examDraftModel
->select('exam_drafts.*, cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('users u', 'u.id = exam_drafts.teacher_id', 'left')
->join('users a', 'a.id = exam_drafts.admin_id', 'left')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
foreach ($allDrafts as &$row) {
if (empty($row['final_pdf_file'])) {
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
if ($pdf !== null) {
$row['final_pdf_file'] = $pdf;
}
}
}
unset($row);
$classSections = $this->classSectionModel
->select('class_section_id, class_section_name')
->orderBy('class_section_name', 'ASC')
->findAll();
// Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab
$legacyByClass = [];
if ($this->hasIsLegacyColumn) {
// Keep all submissions visible; additionally surface legacy items in a separate tab
$drafts = $allDrafts;
foreach ($allDrafts as $d) {
$isLegacy = !empty($d['is_legacy']);
if (!$isLegacy) {
continue;
}
$cid = (int)($d['class_section_id'] ?? 0);
if (!isset($legacyByClass[$cid])) {
$legacyByClass[$cid] = [
'class_section_id' => $cid,
'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
'items' => [],
];
}
$legacyByClass[$cid]['items'][] = $d;
}
} else {
// Column missing: keep behavior simple and avoid legacy tab
$drafts = $allDrafts;
}
return view('administrator/exam_drafts', [
'drafts' => $drafts,
'statusBadges' => $this->statusBadgeMap(),
'statusOptions' => $this->statusOptions(),
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS,
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
'examTypes' => $this->examTypes,
'classSections' => $classSections,
'legacyByClass' => $legacyByClass,
]);
}
public function adminUploadLegacy()
{
$adminId = (int) (session()->get('user_id') ?? 0);
if ($adminId <= 0) {
return redirect()->to('/login');
}
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
$examType = trim((string) $this->request->getPost('exam_type'));
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section.');
}
if ($schoolYear === '') {
return redirect()->back()->withInput()->with('error', 'School year is required.');
}
if ($semester === '') {
return redirect()->back()->withInput()->with('error', 'Semester is required.');
}
$file = $this->request->getFile('old_exam_file');
if (!$file || !$file->isValid()) {
return redirect()->back()->withInput()->with('error', 'A valid file is required.');
}
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS);
if ($stored === null) {
return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.');
}
$payload = [
'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only
'class_section_id' => $classSectionId,
'semester' => ucfirst(strtolower($semester)),
'school_year' => $schoolYear,
'exam_type' => $examType === '' ? null : $examType,
'draft_title' => $examType === '' ? 'Legacy Exam' : $examType,
'description' => null,
'final_file' => $stored,
'final_filename' => $file->getClientName(),
'status' => 'finalized',
'admin_id' => $adminId,
'reviewed_at' => utc_now(),
'version' => 1,
];
if ($this->hasIsLegacyColumn) {
$payload['is_legacy'] = 1;
}
$pdfName = null;
if (strtolower($file->getClientExtension()) === 'pdf') {
$pdfName = $stored;
} else {
$pdfName = $this->convertDocToPdf(
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored),
self::FINAL_UPLOAD_DIR
);
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$payload['final_pdf_file'] = $pdfName;
}
if ($this->examDraftModel->insert($payload)) {
return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.');
}
public function adminReview()
{
$draftId = (int) ($this->request->getPost('draft_id') ?? 0);
if ($draftId <= 0) {
return redirect()->back()->with('error', 'Invalid submission selected.');
}
$draft = $this->examDraftModel->find($draftId);
if (empty($draft)) {
return redirect()->back()->with('error', 'Submission not found.');
}
$comments = trim((string) $this->request->getPost('admin_comments'));
$statusInput = trim((string) $this->request->getPost('review_status'));
$currentStatus = (string) ($draft['status'] ?? 'draft');
$status = $this->normalizeStatus(
$statusInput !== '' ? $statusInput : $currentStatus,
$currentStatus
);
$status = strtolower($status);
if (!in_array($status, $this->statusOptions(), true)) {
$status = 'reviewed';
}
$finalFile = null;
$finalFilename = null;
$file = $this->request->getFile('final_file');
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR);
if ($stored === null) {
return redirect()->back()->with('error', 'Unable to store the final draft.')->withInput();
}
$finalFile = $stored;
$finalFilename = $file->getClientName();
// Only auto-finalize if the admin explicitly chose "finalized"
// (previously any uploaded file forced finalization)
if ($status === 'finalized') {
$status = 'finalized';
}
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
return redirect()->back()->with('error', 'Final file upload failed.');
}
$update = [
'status' => $status,
'admin_comments' => $comments === '' ? null : $comments,
'admin_id' => (int) (session()->get('user_id') ?? 0),
'reviewed_at' => utc_now(),
];
if ($finalFile !== null) {
$update['final_file'] = $finalFile;
$update['final_filename'] = $finalFilename;
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
} elseif (strtolower($status) === 'finalized' && !empty($draft['teacher_file'])) {
// Auto-promote teacher file when admin finalizes without uploading a final
$copied = $this->copyDraftToFinal($draft['teacher_file']);
if ($copied !== null) {
$update['final_file'] = $copied;
$update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file'];
$pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION));
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
}
}
if (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) {
$update['is_legacy'] = 1; // finalized exams move to Previous Exams
}
if ($this->hasIsLegacyColumn) {
// Keep existing legacy flag, do not set legacy automatically here
}
$updated = $this->examDraftModel
->set($update)
->where('id', $draftId)
->update();
if ($updated) {
return redirect()->back()->with('success', 'Review saved successfully.');
}
return redirect()->back()->with('error', 'Unable to save the review.');
}
private function resolveSelectedClassSection(array $assignments): int
{
$candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0);
$validIds = array_map('intval', array_column($assignments, 'class_section_id'));
if ($candidate > 0 && in_array($candidate, $validIds, true)) {
return $candidate;
}
return $validIds[0] ?? 0;
}
private function statusBadgeMap(): array
{
return [
'draft' => [
'label' => 'Draft',
'class' => 'bg-secondary text-white',
],
'submitted' => [
'label' => 'Submitted',
'class' => 'bg-warning text-dark',
],
'reviewed' => [
'label' => 'Reviewed',
'class' => 'bg-info text-dark',
],
'finalized' => [
'label' => 'Finalized',
'class' => 'bg-success text-white',
],
'rejected' => [
'label' => 'Rejected',
'class' => 'bg-danger text-white',
],
];
}
private function statusOptions(): array
{
return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected'];
}
private function normalizeStatus(string $input, string $default): string
{
$input = strtolower(trim($input));
$aliases = [
'pending' => 'submitted', // legacy value
'final' => 'finalized',
'approved' => 'finalized', // legacy value
];
if (isset($aliases[$input])) {
$input = $aliases[$input];
}
return in_array($input, $this->statusOptions(), true) ? $input : $default;
}
private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string
{
$ext = strtolower($file->getClientExtension());
if (!in_array($ext, $allowedExtensions, true)) {
return null;
}
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
return null;
}
$destination = WRITEPATH . 'uploads/' . trim($subdir, '/');
if (!is_dir($destination)) {
mkdir($destination, 0755, true);
}
$filename = $file->getRandomName();
try {
$file->move($destination, $filename);
return $filename;
} catch (\Throwable $e) {
log_message('error', 'ExamDraftController::storeUploadedFile error: ' . $e->getMessage());
return null;
}
}
private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string
{
// Only attempt conversion for doc/docx
$ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION));
if (!in_array($ext, ['doc', 'docx'], true)) {
return null;
}
$targetDir = WRITEPATH . 'uploads/' . trim($targetSubdir, '/');
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
// Attempt conversion via LibreOffice if available
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@exec($cmd);
return is_file($targetPath) ? basename($targetPath) : null;
}
private function schemaHasColumn(string $table, string $column): bool
{
try {
$fields = $this->db->getFieldNames($table);
return in_array($column, $fields, true);
} catch (\Throwable $e) {
log_message('error', "Schema check failed for {$table}.{$column}: " . $e->getMessage());
return false;
}
}
private function fullUploadPath(string $subdir, string $filename): string
{
return WRITEPATH . 'uploads/' . trim($subdir, '/') . '/' . $filename;
}
private function neighborPdfIfExists(?string $filename, string $subdir): ?string
{
if (empty($filename)) return null;
$path = $this->fullUploadPath($subdir, $filename);
$base = pathinfo($path, PATHINFO_FILENAME);
$dir = pathinfo($path, PATHINFO_DIRNAME);
$pdfPath = $dir . '/' . $base . '.pdf';
return is_file($pdfPath) ? basename($pdfPath) : null;
}
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
{
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
if ($pdfNeighbor !== null) {
return $pdfNeighbor;
}
$ext = strtolower((string)$originalExt);
if ($ext === 'pdf') {
// final file itself is already pdf
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename);
return is_file($path) ? $finalFilename : null;
}
return $this->convertDocToPdf(
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
self::FINAL_UPLOAD_DIR
);
}
private function copyDraftToFinal(string $draftFilename): ?string
{
$source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename);
if (!is_file($source)) {
return null;
}
$destinationDir = WRITEPATH . 'uploads/' . trim(self::FINAL_UPLOAD_DIR, '/');
if (!is_dir($destinationDir)) {
mkdir($destinationDir, 0755, true);
}
$ext = pathinfo($draftFilename, PATHINFO_EXTENSION);
$destName = uniqid('final_', true) . '.' . $ext;
$destPath = $destinationDir . '/' . $destName;
if (!@copy($source, $destPath)) {
return null;
}
return $destName;
}
}
+348
View File
@@ -0,0 +1,348 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ExpenseModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class ExpenseController extends BaseController
{
protected $expenseModel;
protected $userModel;
protected $configModel;
protected $schoolYear;
protected $semester;
protected $retailors;
public function __construct()
{
$this->expenseModel = new ExpenseModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
// Default list of common retailors; adjust as needed
$this->retailors = [
'Amazon',
'Walmart',
'Costco',
'BJ\'s',
'Market Basket',
'Aldi',
'Hannaford',
'Sam\'s Club',
'HomeGoods',
'Hostinger',
'Wicked Cheesy',
'Shatila',
'Brothers Pizzeria',
'Paradise Biryani Pointe',
'Emad Leiman',
'Nova Trampoline Park',
'Lubin\'s Awards',
'Dollar Tree',
'Stop & Shop',
'Dunkin\' Donuts',
'Giovanni\'s Pizza',
'Trader Joes'
];
}
/**
* Return staff users (admins/teachers/etc.) excluding parents/guests.
*/
private function staffUsers(): array
{
$rows = $this->userModel
->select('users.id, users.firstname, users.lastname, roles.name AS role_name')
->join('user_roles', 'user_roles.user_id = users.id', 'left')
->join('roles', 'roles.id = user_roles.role_id', 'left')
->where('roles.name IS NOT NULL', null, false)
->findAll();
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
$staff = [];
foreach ($rows as $row) {
$roleName = strtolower((string) ($row['role_name'] ?? ''));
$id = (int) ($row['id'] ?? 0);
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
continue;
}
if (!isset($staff[$id])) {
$staff[$id] = [
'id' => $id,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
];
}
}
uasort($staff, static function ($a, $b) {
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($nameA, $nameB);
});
return array_values($staff);
}
public function index()
{
$expenses = $this->expenseModel
->select("
expenses.*,
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
")
->join('users u', 'u.id = expenses.purchased_by', 'left')
->join('users approver', 'approver.id = expenses.approved_by', 'left')
->orderBy('expenses.created_at', 'DESC')
->findAll();
// Enrich each row with a URL that goes through Files::receipt($name)
// We store only the filename in 'receipt_path' (e.g., "1759...f62.png")
$expenses = array_map(function ($row) {
$name = $row['receipt_path'] ?? null;
$row['receipt_url'] = $this->receiptUrl($name);
return $row;
}, $expenses);
return view('expenses/index', ['expenses' => $expenses]);
}
public function create()
{
$users = $this->staffUsers();
return view('expenses/create', [
'users' => $users,
'retailors' => $this->retailors,
]);
}
public function store()
{
$rules = [
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
'amount' => 'required|decimal|greater_than[0]',
// Frontend sends purchased_by as "id|Full Name"
'purchased_by' => 'required',
// Optional extra fields
'retailor' => 'permit_empty|max_length[255]',
'date_of_purchase' => 'permit_empty',
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
'receipt' => 'uploaded[receipt]'
. '|max_size[receipt,2048]'
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
];
$messages = [
'receipt' => [
'uploaded' => 'Receipt file is required.',
'max_size' => 'Maximum file size is 2MB.',
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
]
];
if (!$this->validate($rules, $messages)) {
return redirect()->back()->withInput()->with('error', $this->validator->listErrors());
}
// Safe values
$category = (string) $this->request->getPost('category');
$amount = (string) $this->request->getPost('amount');
$description = (string) $this->request->getPost('description');
$retailor = trim((string) $this->request->getPost('retailor'));
$datePurchase = (string) $this->request->getPost('date_of_purchase');
$userId = (int) (session()->get('user_id') ?? 0);
$isDonation = ($category === 'Donation');
// Parse "purchased_by" as "7|John Doe"
$purchasedInfo = (string) $this->request->getPost('purchased_by');
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
$purchasedById = (int) $purchasedById;
// School context
$schoolYear = $this->schoolYear ?: date('Y');
$semester = $this->semester ?: 'Fall';
// Handle upload: store under writable/uploads/receipts and save only the filename
$receiptName = null;
$file = $this->request->getFile('receipt');
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
$receiptName = basename($stored);
}
$status = $isDonation ? 'approved' : 'pending';
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
$this->expenseModel->insert([
'category' => $category,
'amount' => $amount,
'receipt_path' => $receiptName, // filename only
'description' => $description,
'retailor' => ($retailor !== '') ? $retailor : null,
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
'purchased_by' => $purchasedById,
'added_by' => $userId,
'status' => $status,
'status_reason'=> $statusReason,
'approved_by' => $isDonation ? $userId : null,
'school_year' => $schoolYear,
'semester' => $semester,
]);
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
}
public function updateStatus()
{
$data = $this->request->getJSON(true);
$id = isset($data['id']) ? (int)$data['id'] : null;
$status = $data['status'] ?? null;
$reason = $data['reason'] ?? '';
$userId = (int) (session()->get('user_id') ?? 0);
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
log_message('error', 'Invalid status or ID');
return $this->response->setJSON(['error' => 'Invalid data']);
}
$expense = $this->expenseModel->find($id);
if (!$expense) {
log_message('error', 'Expense not found for ID ' . $id);
return $this->response->setJSON(['error' => 'Expense not found']);
}
$success = $this->expenseModel->update($id, [
'status' => $status,
'status_reason' => $reason,
'approved_by' => $userId,
'updated_by' => $userId
]);
if (!$success) {
log_message('error', 'Expense update failed for ID ' . $id);
return $this->response->setJSON(['error' => 'Update failed']);
}
return $this->response->setJSON(['success' => true]);
}
/**
* Build a public URL for a receipt filename through Files::receipt($name).
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
*/
private function receiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
// Route should be defined as: $routes->get('receipts/(:any)', 'Files::receipt/$1');
return site_url('receipts/' . $filename);
}
public function edit(int $id)
{
$expense = $this->expenseModel->find($id);
if (!$expense) {
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
}
// same user list you use in create()
$users = $this->staffUsers();
return view('expenses/edit', [
'expense' => $expense,
'users' => $users,
'retailors' => $this->retailors,
'receipt_url' => $expense['receipt_path'] ? site_url('receipts/' . basename($expense['receipt_path'])) : null,
]);
}
public function update(int $id)
{
helper(['form']);
$expense = $this->expenseModel->find($id);
if (!$expense) {
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
}
// Base rules
$rules = [
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
'amount' => 'required|decimal|greater_than[0]',
'purchased_by' => 'required', // still "id|Full Name"
'retailor' => 'permit_empty|max_length[255]',
'date_of_purchase' => 'permit_empty',
];
// Optional new receipt validation (only if provided)
$file = $this->request->getFile('receipt');
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
$rules['receipt'] = 'max_size[receipt,2048]'
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]';
}
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
// Parse "id|Name"
[$purchasedById] = array_pad(explode('|', (string) $this->request->getPost('purchased_by'), 2), 2, null);
$purchasedById = (int) $purchasedById;
$category = (string) $this->request->getPost('category');
$isDonation = ($category === 'Donation');
$userId = (int) (session()->get('user_id') ?? 0);
// Keep old receipt unless replaced or removed
$receiptName = $expense['receipt_path'];
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
$stored = $file->store('receipts');
$receiptName = basename($stored);
}
if ($this->request->getPost('remove_receipt') === '1') {
$receiptName = null;
}
$updateData = [
'category' => $category,
'amount' => (string) $this->request->getPost('amount'),
'description' => (string) $this->request->getPost('description'),
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
'purchased_by' => $purchasedById,
'receipt_path' => $receiptName,
'updated_by' => $userId,
];
if ($isDonation) {
$updateData['status'] = 'approved';
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
$updateData['approved_by'] = $userId ?: null;
$updateData['reimbursement_id'] = null;
} elseif (($expense['category'] ?? '') === 'Donation') {
// Moving a donation back to a reimbursable category: clear the marker.
$updateData['status_reason'] = null;
$updateData['approved_by'] = $expense['approved_by'] ?? null;
$updateData['status'] = $expense['status'] ?? 'pending';
}
$this->expenseModel->update($id, $updateData);
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
}
}

Some files were not shown because too many files have changed in this diff Show More