recreate project
This commit is contained in:
@@ -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}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class CheckMissedPayments extends BaseCommand
|
||||
{
|
||||
protected $group = 'Payments';
|
||||
protected $name = 'payments:check-missed';
|
||||
protected $description = 'Checks for users who missed payments and sends reminders.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$userModel = new UserModel();
|
||||
|
||||
// Fetch users who missed payment (you need to implement this query)
|
||||
$missedUsers = $userModel->getUsersWithMissedPayments();
|
||||
|
||||
foreach ($missedUsers as $user) {
|
||||
NotificationService::toUser(
|
||||
$user['id'],
|
||||
'Payment Missed',
|
||||
'You have a missed payment. Please pay to avoid penalty.',
|
||||
['in_app', 'email', 'sms']
|
||||
);
|
||||
CLI::write("Reminder sent to {$user['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Finished checking missed payments.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\NotificationModel;
|
||||
|
||||
class CleanupExpiredNotifications extends BaseCommand
|
||||
{
|
||||
protected $group = 'Maintenance';
|
||||
protected $name = 'notifications:cleanup';
|
||||
protected $description = 'Deletes expired notifications from the database.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$model = new NotificationModel();
|
||||
|
||||
// Fetch expired notifications
|
||||
$expired = $model->where('expires_at IS NOT NULL')
|
||||
->where('expires_at < NOW()')
|
||||
->findAll();
|
||||
|
||||
if (empty($expired)) {
|
||||
CLI::write("ℹ No expired notifications found to soft delete.", 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($expired as $note) {
|
||||
$model->delete($note['id']); // Soft delete
|
||||
$count++;
|
||||
}
|
||||
|
||||
CLI::write("✅ Soft-deleted {$count} expired notifications.", 'green');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\PasswordResetRequestModel;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class CleanupPasswordResets extends BaseCommand
|
||||
{
|
||||
protected $group = 'Maintenance';
|
||||
protected $name = 'cleanup:password-resets';
|
||||
protected $description = 'Delete password reset requests older than 30 days';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$model = new PasswordResetRequestModel();
|
||||
$threshold = Time::now()->subDays(30)->toDateTimeString();
|
||||
|
||||
$count = $model->where('requested_at <', $threshold)->delete();
|
||||
|
||||
CLI::write("Deleted $count old password reset request(s).", 'green');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Add this cron job to run every 24hrs
|
||||
0 0 * * * /usr/bin/php /path/to/project/public/index.php cleanup:password-resets >> /path/to/project/writable/logs/cleanup.log 2>&1
|
||||
|
||||
*/
|
||||
@@ -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 what’s persisted
|
||||
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
|
||||
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
|
||||
|
||||
return $ok && ($after === $value);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 1 Fetch inactive users older than 15 min
|
||||
// ─────────────────────────────────────────────────────
|
||||
$users = $db->table('users')
|
||||
->select('id, firstname, lastname, email, created_at')
|
||||
->where('status', 'Inactive')
|
||||
->where('created_at <', $cutoffTime)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($users)) {
|
||||
CLI::write('No inactive users found to delete.', 'yellow');
|
||||
$this->purgeOrphanedUserRoles($db);
|
||||
return;
|
||||
}
|
||||
|
||||
CLI::write('Found ' . count($users) . ' users for deletion.', 'green');
|
||||
|
||||
// collect IDs
|
||||
$userIds = array_column($users, 'id');
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 2 Transaction: delete parents → user_roles → users
|
||||
// ─────────────────────────────────────────────────────
|
||||
$db->transStart();
|
||||
|
||||
// 2-a Delete any secondary-parent rows tied to these users
|
||||
$parentsBuilder = $db->table('parents');
|
||||
$deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete();
|
||||
CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue');
|
||||
log_message('info', "Deleted $deletedParents rows from parents table.");
|
||||
|
||||
// 2-b Delete user_roles rows
|
||||
$db->table('user_roles')->whereIn('user_id', $userIds)->delete();
|
||||
CLI::write('Deleted related user_roles rows.', 'blue');
|
||||
|
||||
// 2-c Delete users
|
||||
$db->table('users')->whereIn('id', $userIds)->delete();
|
||||
CLI::write('Deleted users: ' . implode(', ', $userIds), 'green');
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if (!$db->transStatus()) {
|
||||
CLI::write('Transaction failed; rolled back.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
// Purge any stray user_role rows left behind (defensive)
|
||||
$this->purgeOrphanedUserRoles($db);
|
||||
|
||||
$msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows.";
|
||||
CLI::write($msg, 'green');
|
||||
log_message('info', $msg);
|
||||
} catch (\Throwable $e) {
|
||||
CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red');
|
||||
log_message('error', 'Error deleting inactive users: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user_roles rows that point to non-existent users.
|
||||
*/
|
||||
private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void
|
||||
{
|
||||
$orphaned = $db->table('user_roles')
|
||||
->whereNotIn('user_id', function ($q) use ($db) {
|
||||
$q->select('id')->from('users');
|
||||
})
|
||||
->delete();
|
||||
|
||||
if ($orphaned) {
|
||||
CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green');
|
||||
log_message('info', "Deleted $orphaned orphaned user_roles rows.");
|
||||
} else {
|
||||
CLI::write('No orphaned user_roles rows found.', 'yellow');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 today’s absent records (replace with your logic)
|
||||
$absentRecords = $attendanceModel->getTodayAbsentees();
|
||||
|
||||
foreach ($absentRecords as $record) {
|
||||
// Get parent user ID
|
||||
$parent = $userModel->find($record['parent_id']);
|
||||
if (!$parent) continue;
|
||||
|
||||
NotificationService::toUser(
|
||||
$parent['id'],
|
||||
'Daily Attendance Update',
|
||||
"{$record['student_name']} was marked absent on {$record['date']}.",
|
||||
['in_app', 'email']
|
||||
);
|
||||
|
||||
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Attendance summary completed.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -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 today’s late records (replace with your logic)
|
||||
$lateRecords = $attendanceModel->getTodayLates();
|
||||
|
||||
foreach ($lateRecords as $record) {
|
||||
// Get parent user ID
|
||||
$parent = $userModel->find($record['parent_id']);
|
||||
if (!$parent) continue;
|
||||
|
||||
NotificationService::toUser(
|
||||
$parent['id'],
|
||||
'Daily Attendance Update',
|
||||
"{$record['student_name']} was marked late on {$record['date']}.",
|
||||
['in_app', 'email']
|
||||
);
|
||||
|
||||
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Attendance summary completed.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 can’t 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\ForeignCharacters as BaseForeignCharacters;
|
||||
|
||||
/**
|
||||
* @immutable
|
||||
*/
|
||||
class ForeignCharacters extends BaseForeignCharacters
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
// ],
|
||||
];
|
||||
}
|
||||
@@ -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_';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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 = [];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------
|
||||
}
|
||||
@@ -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
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class DocsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('docs/index');
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 user’s 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 don’t 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,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 A–Z, 0–9 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];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 PHPMailer’s 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user