recreate project

This commit is contained in:
root
2026-02-10 22:11:06 -05:00
commit 663c0cdbda
10149 changed files with 1379710 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services;
use CodeIgniter\HTTP\CURLRequest;
use Config\Api as ApiConfig;
class ApiClient
{
protected CURLRequest $http;
protected ApiConfig $config;
public function __construct(CURLRequest $http, ApiConfig $config)
{
$this->http = $http;
$this->config = $config;
}
public function get(string $uri, array $query = [], array $headers = []): array
{
return $this->request('get', $uri, [
'headers' => $this->mergeHeaders($headers),
'query' => $query,
]);
}
public function post(string $uri, array $data = [], array $headers = []): array
{
return $this->request('post', $uri, [
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
'json' => $data,
]);
}
public function put(string $uri, array $data = [], array $headers = []): array
{
return $this->request('put', $uri, [
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
'json' => $data,
]);
}
public function delete(string $uri, array $data = [], array $headers = []): array
{
return $this->request('delete', $uri, [
'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']),
'json' => $data,
]);
}
/**
* Low-level access for uncommon HTTP methods or options
*/
public function request(string $method, string $uri, array $options = []): array
{
$url = $this->fullUrl($uri);
$options['timeout'] = $options['timeout'] ?? $this->config->timeout;
$response = $this->http->request(strtoupper($method), $url, $options);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
$decoded = null;
if ($this->isJson($body)) {
$decoded = json_decode($body, true);
}
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'headers' => $response->getHeaderLine('Content-Type'),
'data' => $decoded,
'raw' => $decoded === null ? $body : null,
];
}
protected function fullUrl(string $uri): string
{
if (preg_match('#^https?://#i', $uri)) {
return $uri;
}
$base = rtrim($this->config->baseURL ?? '', '/');
if ($base === '') {
return '/' . ltrim($uri, '/');
}
return $base . '/' . ltrim($uri, '/');
}
protected function mergeHeaders(array $override = [], array $extra = []): array
{
$base = $this->config->defaultHeaders ?? [];
return array_filter(array_merge($base, $extra, $override), static function ($v) {
return $v !== null && $v !== '';
});
}
protected function isJson(string $string): bool
{
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
}
?>
@@ -0,0 +1,155 @@
<?php
namespace App\Services\Calculators;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\AttendanceRecordModel;
use App\Models\ConfigurationModel;
use App\Models\CalendarModel;
use RuntimeException;
class AttendanceCalculator implements ScoreCalculatorInterface
{
private $attendanceModel;
private $configModel;
private $calendarModel;
public function __construct(
AttendanceRecordModel $attendanceModel,
ConfigurationModel $configModel,
CalendarModel $calendarModel
) {
$this->attendanceModel = $attendanceModel;
$this->configModel = $configModel;
$this->calendarModel = $calendarModel;
}
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$absences = $this->attendanceModel->getTotalAbsences($studentId, $semester, $schoolYear);
$totalDays = null;
$normSemester = $this->normalizeSemester($semester);
if ($schoolYear !== '' && $normSemester !== '') {
$semRange = $this->getSemesterRange($schoolYear, $normSemester);
if ($semRange) {
try {
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($schoolYear, $normSemester);
$noSchoolSundays = 0;
foreach ($events as $event) {
if (!empty($event['no_school'])) {
$date = new \DateTime($event['date']);
if ($date->format('N') == 7) {
if ($date->format('Y-m-d') >= $semRange[0] && $date->format('Y-m-d') <= $semRange[1]) {
$noSchoolSundays++;
}
}
}
}
$totalDays = $this->countSundays($semRange[0], $semRange[1]) - $noSchoolSundays;
} catch (\Throwable $e) {
log_message('error', 'Failed to count sundays for attendance score: ' . $e->getMessage());
$totalDays = null;
}
}
}
if ($totalDays === null) {
$totalDays = $this->getConfiguredSemesterLength($semester);
} else {
$configuredDays = $this->getConfiguredSemesterLength($semester);
if ($configuredDays !== null) {
$totalDays = $configuredDays;
}
}
if ($absences === null || $totalDays === null || $totalDays < 0) {
throw new RuntimeException("Invalid attendance data: could not determine total days or absences for student {$studentId}.");
}
if ($totalDays == 0) {
return ['attendance_score' => 100.0];
}
$attendanceRatio = ($totalDays + 1 - $absences) / $totalDays;
$score = min(100, max(0, $attendanceRatio * 100));
return ['attendance_score' => round($score, 2)];
}
protected function normalizeSemester(?string $input): string
{
$s = strtolower(trim((string)$input));
if ($s === 'fall' || str_contains($s, '1')) return 'fall';
if ($s === 'spring' || str_contains($s, '2')) return 'spring';
return '';
}
protected function getSemesterRange(string $schoolYear, string $semester): ?array
{
$norm = $this->normalizeSemester($semester);
if ($norm === '' || !preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $m)) {
// Fallback for single year format
if ($norm === '' || !preg_match('/^(\d{4})/', trim($schoolYear), $m)) {
return null;
}
$y1 = (int)$m[1];
$y2 = $y1 + 1;
} else {
$y1 = (int)$m[1];
$y2 = (int)$m[2];
}
$fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
$fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? '');
$springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? '');
$springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? '');
if ($norm === 'fall') {
$start = ($fallStartCfg !== '') ? sprintf('%04d-%s', $y1, date('m-d', strtotime($fallStartCfg))) : "{$y1}-09-01";
$end = ($fallEndCfg !== '') ? sprintf('%04d-%s', $y2, date('m-d', strtotime($fallEndCfg))) : "{$y2}-01-15";
return [$start, $end];
}
if ($norm === 'spring') {
$start = ($springStartCfg !== '') ? sprintf('%04d-%s', $y2, date('m-d', strtotime($springStartCfg))) : "{$y2}-01-16";
$end = ($springEndCfg !== '') ? sprintf('%04d-%s', $y2, date('m-d', strtotime($springEndCfg))) : "{$y2}-06-30";
return [$start, $end];
}
return null;
}
private function countSundays(string $startDate, string $endDate): int
{
$start = new \DateTime($startDate);
$end = new \DateTime($endDate);
$sundays = 0;
$interval = new \DateInterval('P1D');
$period = new \DatePeriod($start, $interval, $end->modify('+1 day')); // include end date
foreach ($period as $day) {
if ($day->format('N') == 7) {
$sundays++;
}
}
return $sundays;
}
private function getConfiguredSemesterLength(string $semester): ?int
{
$norm = $this->normalizeSemester($semester);
if ($norm === '') {
return null;
}
$configKey = $norm === 'fall' ? 'total_semester1_days' : 'total_semester2_days';
$value = $this->configModel->getConfig($configKey);
if (!is_numeric($value)) {
return null;
}
$length = (int) $value;
return $length > 0 ? $length : null;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Calculators;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\HomeworkModel;
class HomeworkCalculator implements ScoreCalculatorInterface
{
private $homeworkModel;
public function __construct(HomeworkModel $homeworkModel)
{
$this->homeworkModel = $homeworkModel;
}
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$average = $this->homeworkModel->getAverageHomeworkScore($studentId, $semester, $schoolYear, $classSectionId);
return ['homework_avg' => $average !== null ? (float) $average : null];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Calculators;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\ProjectModel;
class ProjectCalculator implements ScoreCalculatorInterface
{
private $projectModel;
public function __construct(ProjectModel $projectModel)
{
$this->projectModel = $projectModel;
}
/**
* Calculate the average project score for a student
*
* @param int $studentId
* @param string $semester
* @param string $schoolYear
* @return array Returns ['project_avg' => float|null]
* @throws \RuntimeException If no project scores are found
*/
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$average = $this->projectModel->getAverageProjectScore($studentId, $semester, $schoolYear, $classSectionId);
return ['project_avg' => $average !== null ? (float) $average : null];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Calculators;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\QuizModel;
class QuizCalculator implements ScoreCalculatorInterface
{
private $quizModel;
public function __construct(QuizModel $quizModel)
{
$this->quizModel = $quizModel;
}
public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$average = $this->quizModel->getAverageQuizScore($studentId, $semester, $schoolYear, $classSectionId);
return ['quiz_avg' => $average !== null ? (float) $average : null];
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Services;
use App\Controllers\View\EmailController;
class EmailService
{
protected array $senders;
protected EmailController $mailer;
public function __construct()
{
$json = env('MAIL_SENDERS', '{}');
$decoded = json_decode($json, true);
$this->senders = is_array($decoded) ? $decoded : [];
$this->mailer = new EmailController();
}
protected function getSenderDetails(string $fromKey = 'general'): array
{
$senders = $this->senders;
if (isset($senders[$fromKey])) {
return $senders[$fromKey];
}
return $senders['general'] ?? [
'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org',
'name' => 'Al Rahma Sunday School',
];
}
/** NEW: expose configured sender keys to build a dropdown in the UI */
public function listSenders(): array
{
// returns: ['general' => ['email' => '...', 'name' => '...'], 'finance' => [...], ...]
return $this->senders ?: [
'general' => [
'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org',
'name' => 'Al Rahma Sunday School',
],
];
}
/**
* Send a single HTML email.
* Backward-compatible: you can ignore $attachments/$headers.
*/
public function send(string $to, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
{
$attachmentsPayload = $this->normalizeAttachments($attachments);
// Headers are ignored by EmailController but kept for interface compatibility.
return $this->mailer->sendEmail($to, $subject, $message, $fromKey, null, null, $attachmentsPayload);
}
/**
* NEW: BCC bulk send in a single SMTP call (To must be non-empty; we use the SMTP user).
*/
public function sendBcc(array $bccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
{
if (empty($bccList)) {
return true; // nothing to send
}
$attachmentsPayload = $this->normalizeAttachments($attachments);
$allOk = true;
foreach ($bccList as $addr) {
$ok = $this->mailer->sendEmail($addr, $subject, $message, $fromKey, null, null, $attachmentsPayload);
if (!$ok) {
$allOk = false;
log_message('error', 'EmailService::sendBcc failed for ' . $addr);
}
}
return $allOk;
}
/**
* Normalize various attachment inputs to the format expected by EmailController.
*/
private function normalizeAttachments(array $attachments): array
{
$out = [];
foreach ($attachments as $file) {
if (is_object($file) && method_exists($file, 'isValid') && $file->isValid()) {
$newName = $file->getRandomName();
$path = WRITEPATH . 'uploads/' . $newName;
$file->move(WRITEPATH . 'uploads', $newName, true);
$out[] = ['path' => $path, 'name' => $file->getClientName()];
} elseif (is_string($file) && is_file($file)) {
$out[] = ['path' => $file, 'name' => basename($file)];
} elseif (is_array($file) && !empty($file['path'])) {
$out[] = ['path' => $file['path'], 'name' => $file['name'] ?? basename($file['path'])];
}
}
return $out;
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Services;
use App\Models\ConfigurationModel;
use App\Models\PaymentModel;
use App\Models\InvoiceModel;
use App\Models\ClassSectionModel;
class FeeCalculationService
{
public function calculateRefund(array $students, int $parentId): float
{
$configModel = new ConfigurationModel();
$paymentModel = new PaymentModel();
$invoiceModel = new InvoiceModel();
$classSectionModel = new ClassSectionModel();
$schoolYear = $configModel->getConfig('school_year');
$refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline')));
$weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8);
$schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_school_day')));
$totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
if ($totalPaid <= 0) {
log_message('info', "No payments made. Refund = 0.");
return 0;
}
// Classify and enrich student data
$registeredStudents = [];
$withdrawnStudents = [];
foreach ($students as &$student) {
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
$student['grade'] = strtoupper(trim($gradeName));
if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
$withdrawnStudents[] = $student;
} elseif (
in_array($student['enrollment_status'], ['enrolled', 'payment pending']) &&
$student['admission_status'] === 'accepted'
) {
$registeredStudents[] = $student;
}
}
unset($student);
if (empty($withdrawnStudents)) {
log_message('info', "No withdrawn students found. Refund = 0.");
return 0;
}
// Combine all students for proper fee tiering
$allStudents = array_merge($registeredStudents, $withdrawnStudents);
// Sort all students by grade for correct tiering
usort($allStudents, function ($a, $b) {
return $this->compareGrades($a['grade'], $b['grade']);
});
// Retrieve fee configs
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
// Assign tuition_fee to all students (before filtering refunds)
$regularCount = 0;
foreach ($allStudents as &$student) {
$gradeLevel = $this->getGradeLevel($student['grade']);
if ($gradeLevel > 9) {
$studentFee = $youthFee;
} else {
$studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
$regularCount++;
}
}
unset($student);
// Calculate refund for withdrawn students
$refundAmount = 0;
foreach ($withdrawnStudents as $student) {
if (empty($student['withdrawal_date'])) {
log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}");
continue;
}
$withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date']));
if (strtotime($withdrawDate) > strtotime($refundDeadline)) {
log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student.");
continue;
}
$withdrawDateObj = new \DateTime($withdrawDate);
$schoolEndDateObj = new \DateTime($schoolEndDate);
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
//$studentFee = $student['tuition_fee'];
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
$refundAmount += $proportionalRefund;
log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks.");
}
if ($refundAmount > $totalPaid) {
log_message('info', "Refund capped at total paid amount: {$totalPaid}");
return $totalPaid;
}
log_message('info', "Final calculated refund: {$refundAmount}");
return $refundAmount;
}
private function compareGrades($gradeA, $gradeB)
{
$valA = $this->getGradeLevel($gradeA);
$valB = $this->getGradeLevel($gradeB);
if ($valA !== $valB) return $valA <=> $valB;
// Same level, compare suffix
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeA), $suffixA);
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeB), $suffixB);
return strcmp($suffixA[1] ?? '', $suffixB[1] ?? '');
}
private function getGradeLevel($grade)
{
if (strtoupper($grade) === 'K') return 0;
if (strtoupper($grade) === 'Y') return 99;
if (preg_match('/^(\d+)([A-Z]*)$/i', $grade, $matches)) {
return (int) $matches[1];
}
return 999; // fallback for unknown/malformed grades
}
private function calculateTotalTuitionFee(array $students): float
{
$configModel = new ConfigurationModel();
$classSectionModel = new \App\Models\ClassSectionModel();
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
// ✅ Pre-fetch and assign grade/class section names before sorting
foreach ($students as &$student) {
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
$student['grade'] = strtoupper(trim($gradeName));
}
unset($student); // break reference
// ✅ Sort students by grade
usort($students, function ($a, $b) {
return $this->compareGrades($a['grade'], $b['grade']);
});
$regularCount = 0;
$totalFee = 0;
// ✅ Calculate fee
foreach ($students as $student) {
$gradeLevel = $this->getGradeLevel($student['grade']);
if ($gradeLevel > 9) {
$totalFee += $youthFee;
} else {
$totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
$regularCount++;
}
}
return $totalFee;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services;
use App\Models\NavItemModel;
use App\Models\RoleNavItemModel;
use CodeIgniter\Cache\CacheInterface;
class NavbarService
{
public function __construct(
protected NavItemModel $navModel = new NavItemModel(),
protected RoleNavItemModel $mapModel = new RoleNavItemModel(),
protected ?CacheInterface $cache = null
){
$this->cache ??= cache();
}
public function getMenuForRoles(array $roles): array
{
$roles = array_map(fn($r)=>strtolower(trim((string)$r)), $roles);
$cacheKey = 'navbar_' . md5(json_encode($roles));
if ($menu = $this->cache->get($cacheKey)) {
return $menu;
}
// Which items this user can see
$allowedIds = $this->mapModel->getNavItemIdsForRoles($roles);
if (empty($allowedIds)) return [];
// Load all enabled items, then filter
$rows = $this->navModel->where('is_enabled', 1)
->orderBy('sort_order', 'ASC')
->findAll();
// index by id
$byId = [];
foreach ($rows as $r) {
if (in_array($r['id'], $allowedIds, true)) {
$r['children'] = [];
$byId[$r['id']] = $r;
}
}
// Build tree
$tree = [];
foreach ($byId as $id => &$node) {
$pid = $node['menu_parent_id'];
if ($pid && isset($byId[$pid])) {
$byId[$pid]['children'][] = &$node;
} else {
$tree[] = &$node;
}
}
$this->cache->save($cacheKey, $tree, 300); // 5 minutes
return $tree;
}
public function clearCache(): void
{
// simplest: flush; or if you have a tagged cache, clear only keys
cache()->clean();
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Services;
use CodeIgniter\Events\Events;
class NotificationService
{
/**
* Trigger a notification event by type and payload.
*
* @param string $eventType The name of the event (e.g., 'userRegistered')
* @param array $payload The data to pass to the listener
* @return void
*/
public static function trigger(string $eventType, array $payload): void
{
Events::trigger($eventType, $payload);
}
/**
* Helper for sending to a specific user.
*
* @param int $userId
* @param string $title
* @param string $message
* @param array $channels
* @return void
*/
public static function toUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
{
self::trigger('customNotification', [
'user_id' => $userId,
'title' => $title,
'message' => $message,
'channels' => $channels
]);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Services;
class PhoneFormatterService
{
/**
* Format a raw phone number to (xxx)-xxx-xxxx format
*
* @param string $number
* @return string|null
*/
public function formatPhoneNumber(string $number): ?string
{
// Remove non-digit characters
$digits = preg_replace('/\D/', '', $number);
// Ensure it's exactly 10 digits
if (strlen($digits) === 10) {
return sprintf(
'%s-%s-%s',
substr($digits, 0, 3),
substr($digits, 3, 3),
substr($digits, 6)
);
}
// Invalid number
return null;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Services;
use App\Models\RoleModel;
class RoleService
{
public function __construct(private RoleModel $roles = new RoleModel()) {}
/** @param string[] $roleKeys names or slugs */
public function bestDashboardRouteFor(array $roleKeys): string
{
$rows = $this->roles->findByNamesOrSlugs($roleKeys);
if (!empty($rows)) {
// first row is highest priority due to orderBy('priority','ASC')
return $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
}
return '/landing_page/guest_dashboard';
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use App\Models\UserModel;
use CodeIgniter\Database\BaseConnection;
class SchoolIdService
{
protected $db;
protected $userModel;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->userModel = new UserModel();
}
public function assignSchoolIdToUser($userId)
{
$user = $this->userModel->find($userId);
if (!$user || $user['school_id']) {
return $user['school_id'] ?? null;
}
$schoolId = $this->generateUserSchoolId();
$this->userModel->update($userId, ['school_id' => $schoolId]);
return $schoolId;
}
public function generateStudentSchoolId()
{
$year = date('y');
$maxId = 0;
// Match the real prefix: STU + year
$studentMax = $this->db->table('students')
->select('MAX(school_id) AS max_id')
->like('school_id', "STU{$year}", 'after')
->get()
->getRowArray();
if (!empty($studentMax['max_id'])) {
// Skip "STUyy" (5 chars)
$numericPart = substr($studentMax['max_id'], 5);
$maxId = (int) $numericPart;
}
$nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT);
return "STU{$year}{$nextId}";
}
public function generateUserSchoolId()
{
$year = date('y');
$maxId = 0;
// Get maximum user ID for current year
$userMax = $this->db->table('users')->select('MAX(school_id) AS max_id')
->notLike('school_id', 'S%') // Exclude student IDs
->like('school_id', $year, 'after')->get()->getRowArray();
if (!empty($userMax['max_id'])) {
$numericPart = substr($userMax['max_id'], 2); // Skip 2-digit year
$maxId = max($maxId, (int)$numericPart);
}
$nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT);
return "{$year}{$nextId}";
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Services;
use App\Models\ConfigurationModel;
use DateTimeImmutable;
class SemesterRangeService
{
private ConfigurationModel $configModel;
public function __construct(?ConfigurationModel $configModel = null)
{
$this->configModel = $configModel ?? new ConfigurationModel();
}
public function getSchoolYearRange(string $schoolYear): array
{
$startCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
$endCfg = (string)($this->configModel->getConfig('last_school_day') ?? '');
$start = null;
$end = null;
try {
if ($startCfg !== '') {
$start = new DateTimeImmutable($startCfg);
}
} catch (\Throwable) {
$start = null;
}
try {
if ($endCfg !== '') {
$end = new DateTimeImmutable($endCfg);
}
} catch (\Throwable) {
$end = null;
}
if ($start === null || $end === null) {
if (preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) {
$y1 = (int)$m[1];
$y2 = (int)$m[2];
if ($start === null) {
$start = new DateTimeImmutable($y1 . '-09-01');
}
if ($end === null) {
$end = new DateTimeImmutable($y2 . '-06-30');
}
} else {
$currentYear = (int)date('Y');
if ($start === null) {
$start = new DateTimeImmutable($currentYear . '-09-01');
}
if ($end === null) {
$end = new DateTimeImmutable(($currentYear + 1) . '-06-30');
}
}
}
if ($end < $start) {
[$start, $end] = [$end, $start];
}
return [$start->format('Y-m-d'), $end->format('Y-m-d')];
}
public function getSemesterRange(string $schoolYear, string $semester): ?array
{
if (!preg_match('/^(\\d{4})-(\\d{4})$/', $schoolYear, $m)) {
return null;
}
$y1 = (int)$m[1];
$y2 = (int)$m[2];
$normalized = ucfirst(strtolower(trim($semester)));
if ($normalized === 'Fall') {
return [sprintf('%04d-09-21', $y1), sprintf('%04d-01-18', $y2)];
}
if ($normalized === 'Spring') {
return [sprintf('%04d-01-25', $y2), sprintf('%04d-05-31', $y2)];
}
return null;
}
public function normalizeSemester(?string $semester): string
{
$value = strtolower(trim((string)($semester ?? '')));
if ($value === 'fall') {
return 'Fall';
}
if ($value === 'spring') {
return 'Spring';
}
return '';
}
public function getSemesterForDate(?string $date = null): string
{
$fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
$springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? '');
$lastDayCfg = (string)($this->configModel->getConfig('last_school_day')
?? $this->configModel->getConfig('last_day_of_school') ?? '');
try {
$target = new DateTimeImmutable($date ?: 'now');
} catch (\Throwable) {
return '';
}
try {
$fallStart = $fallStartCfg !== '' ? new DateTimeImmutable($fallStartCfg) : null;
} catch (\Throwable) {
$fallStart = null;
}
try {
$springStart = $springStartCfg !== '' ? new DateTimeImmutable($springStartCfg) : null;
} catch (\Throwable) {
$springStart = null;
}
try {
$lastDay = $lastDayCfg !== '' ? new DateTimeImmutable($lastDayCfg) : null;
} catch (\Throwable) {
$lastDay = null;
}
if ($fallStart === null || $springStart === null || $lastDay === null) {
return '';
}
$target = $target->setTime(0, 0, 0);
$fallStart = $fallStart->setTime(0, 0, 0);
$springStart = $springStart->setTime(0, 0, 0);
$lastDay = $lastDay->setTime(0, 0, 0);
$nextFallStart = $fallStart <= $lastDay ? $fallStart->modify('+1 year') : $fallStart;
if (($fallStart <= $target && $target < $springStart)
|| ($lastDay <= $target && $target < $nextFallStart)
) {
return 'Fall';
}
if ($springStart <= $target && $target < $lastDay) {
return 'Spring';
}
return '';
}
}
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace App\Services;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\SemesterScoreModel;
use App\Models\ConfigurationModel;
use App\Models\FinalExamModel;
use App\Models\MidtermExamModel;
use App\Models\ParticipationModel;
use RuntimeException;
class SemesterScoreService
{
private SemesterScoreModel $semesterScoreModel;
protected ConfigurationModel $configModel;
protected string $semester;
protected string $schoolYear;
/** Who is performing the update (nullable if unknown) */
private ?int $actorId = null;
/** @var array<string, ScoreCalculatorInterface> */
private array $scoreCalculators = [];
public function __construct(
SemesterScoreModel $semesterScoreModel,
ConfigurationModel $configModel,
ScoreCalculatorInterface ...$scoreCalculators
) {
$this->semesterScoreModel = $semesterScoreModel;
$this->configModel = $configModel;
$this->semester = (string) $this->configModel->getConfig('semester');
$this->schoolYear = (string) $this->configModel->getConfig('school_year');
// Default actor from session (can be overridden later)
$this->actorId = (int) (session()->get('user_id') ?? 0) ?: null;
foreach ($scoreCalculators as $calculator) {
$this->scoreCalculators[get_class($calculator)] = $calculator;
}
}
/**
* Update scores for multiple students.
* - If $semester/$schoolYear not provided, they default from service config.
* - If $updatedBy is provided, it overrides the default actor for this batch.
* - Each student can carry its own 'updated_by'; that per-student value wins.
*/
public function updateScoresForStudents(
array $students,
?string $semester = null,
?string $schoolYear = null
): void {
// Defaults from service config if omitted
$semester = $semester ?? $this->semester;
$schoolYear = $schoolYear ?? $this->schoolYear;
foreach ($students as $student) {
$this->updateStudentScores($student, $semester, $schoolYear);
}
}
/**
* Update (compute + persist) the scores for a single student.
*/
public function updateStudentScores(array $student, string $semester, string $schoolYear): void
{
// Validate required student data
foreach (['student_id', 'school_id', 'class_section_id'] as $field) {
if (!isset($student[$field])) {
throw new RuntimeException("Missing required student field: {$field}");
}
}
// Per-student actor resolution:
// 1) student['updated_by'] if present,
// 2) batch/service actorId if set,
// 3) session user as last resort,
// 4) null if none
$updatedBy = isset($student['updated_by'])
? (int) $student['updated_by']
: ($this->actorId ?? ((int) (session()->get('user_id') ?? 0) ?: null));
// Compute all score components
$scoreData = $this->calculateAllScores(
(int) $student['student_id'],
$semester,
$schoolYear,
(int) ($student['class_section_id'] ?? 0) ?: null
);
// Persist (upsert) the scores
$this->saveStudentScores(
(int) $student['student_id'],
(string) $student['school_id'],
(int) $student['class_section_id'],
$updatedBy, // nullable OK
$semester,
$schoolYear,
$scoreData
);
}
/**
* Calculates all components needed for the semester score.
* Returns an associative array of score columns => values (flat).
*/
private function calculateAllScores(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$scoreData = [
'updated_at' => utc_now(),
];
// Calculate individual components via injected calculators
foreach ($this->scoreCalculators as $calculator) {
try {
$scoreData = array_merge(
$scoreData,
$calculator->calculate($studentId, $semester, $schoolYear, $classSectionId)
);
} catch (\Throwable $e) {
log_message(
'error',
'SemesterScoreService calculator failed for student '
. $studentId . ' (' . get_class($calculator) . '): ' . $e->getMessage()
);
}
}
// Participation fallback if calculator didnt provide it
if (!isset($scoreData['participation_score'])) {
$participationModel = new ParticipationModel();
$participationScore = $participationModel->getParticipationScore($studentId, $semester, $schoolYear, $classSectionId);
$scoreData['participation_score'] = $participationScore !== null ? (float) $participationScore : null;
}
// Composite scores
$scoreData['ptap_score'] = $this->calculatePTAPScore($scoreData);
$midfinal = $this->calculateSemesterScore($scoreData, $semester, $studentId, $schoolYear, $classSectionId);
return array_merge($scoreData, [
'semester_score' => $midfinal['semester_score'],
'midterm_exam_score' => $midfinal['midterm_score'],
'final_exam_score' => $midfinal['final_score'],
'participation_score' => $scoreData['participation_score'], // explicit keep
]);
}
/**
* PTAP weighting rules (tests are represented by quiz averages):
* - No tests and no projects: 50% homework, 50% participation.
* - No projects: 40% homework, 40% participation, 20% tests.
* - No tests: 34% homework, 33% participation, 33% projects.
* - All available: 30% homework, 30% participation, 30% projects, 10% tests.
*/
private function calculatePTAPScore(array $scores): ?float
{
$avgHomework = $scores['homework_avg'] ?? null;
$avgQuiz = $scores['quiz_avg'] ?? null;
$avgProject = $scores['project_avg'] ?? null;
$avgParticipation = $scores['participation_score'] ?? null;
$hasTests = $avgQuiz !== null;
$hasProjects = $avgProject !== null;
if (!$hasTests && !$hasProjects) {
if ($avgHomework === null || $avgParticipation === null) {
return null;
}
return round(($avgHomework * 0.5) + ($avgParticipation * 0.5), 1);
}
if (!$hasProjects) {
if ($avgHomework === null || $avgParticipation === null || $avgQuiz === null) {
return null;
}
return round(($avgHomework * 0.4) + ($avgParticipation * 0.4) + ($avgQuiz * 0.2), 1);
}
if (!$hasTests) {
if ($avgHomework === null || $avgParticipation === null || $avgProject === null) {
return null;
}
return round(($avgHomework * 0.34) + ($avgParticipation * 0.33) + ($avgProject * 0.33), 1);
}
if ($avgHomework === null || $avgParticipation === null || $avgProject === null || $avgQuiz === null) {
return null;
}
return round(
($avgHomework * 0.3) + ($avgParticipation * 0.3) + ($avgProject * 0.3) + ($avgQuiz * 0.1),
1
);
}
/**
* Calculates semester score using:
* - Fall: 0.2 * attendance + 0.2 * ptap + 0.6 * midterm
* - Spring: 0.2 * attendance + 0.2 * ptap + 0.6 * final
* Returns null when a required component is missing.
*/
private function calculateSemesterScore(array $scores, string $semester, int $studentId, string $schoolYear, ?int $classSectionId): array
{
$ptap = $scores['ptap_score'] ?? null;
$attendance = $scores['attendance_score'] ?? null;
$out = [
'midterm_score' => null,
'final_score' => null,
'semester_score' => null,
];
if (strcasecmp($semester, 'Fall') === 0) {
$mid = $scores['midterm_exam_score'] ?? (new MidtermExamModel())->getMidtermExamScore($studentId, $semester, $schoolYear, $classSectionId);
$out['midterm_score'] = $mid !== null ? round((float) $mid, 1) : null;
if ($ptap !== null && $attendance !== null && $out['midterm_score'] !== null) {
$out['semester_score'] = round(($attendance * 0.2) + ($ptap * 0.2) + ($out['midterm_score'] * 0.6), 1);
}
return $out;
}
if (strcasecmp($semester, 'Spring') === 0) {
$fin = $scores['final_exam_score'] ?? (new FinalExamModel())->getFinalExamScore($studentId, $semester, $schoolYear, $classSectionId);
$out['final_score'] = $fin !== null ? round((float) $fin, 1) : null;
if ($ptap !== null && $attendance !== null && $out['final_score'] !== null) {
$out['semester_score'] = round(($attendance * 0.2) + ($ptap * 0.2) + ($out['final_score'] * 0.6), 1);
}
return $out;
}
return $out;
}
/**
* Persist (upsert) semester scores for a student.
* Ensure SemesterScoreModel::$allowedFields includes ALL keys we pass here.
*/
private function saveStudentScores(
int $studentId,
string $schoolId,
int $classSectionId,
?int $updatedBy,
string $semester,
string $schoolYear,
array $scoreData
): void {
$base = [
'student_id' => $studentId,
'school_id' => $schoolId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => utc_now(),
];
if ($updatedBy !== null) {
$base['updated_by'] = $updatedBy;
}
// Merge in the computed score fields (e.g., homework_avg, quiz_avg, project_avg, attendance_score, etc.)
$payload = array_merge($base, $scoreData);
// Upsert using your model (make sure allowedFields has all keys in $payload)
$this->semesterScoreModel->upsert($payload);
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
namespace App\Services;
use App\Models\PreferencesModel;
use App\Models\SettingsModel;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\I18n\Time;
class TimeService
{
private string $defaultUserTimezone = 'America/New_York';
private string $serverTimezone = 'UTC';
// Cached per-request user timezone
private ?string $cachedUserTz = null;
/**
* Prime detection cache from the Request (optional call, lazy by default).
*/
public function primeFromRequest(?RequestInterface $request = null): void
{
$this->cachedUserTz = $this->detectUserTimezone($request);
}
/**
* Get the resolved user timezone (detect lazily if needed).
*/
public function userTimezone(?RequestInterface $request = null): string
{
if ($this->cachedUserTz !== null) {
return $this->cachedUserTz;
}
$this->cachedUserTz = $this->detectUserTimezone($request);
return $this->cachedUserTz;
}
/**
* Core detection logic (headers > user pref > settings > school config > default).
*/
public function detectUserTimezone(?RequestInterface $request = null): string
{
$request = $request ?: service('request');
// 1) Explicit client header
try {
$tzHeader = $request->getHeaderLine('X-Timezone')
?: $request->getHeaderLine('Timezone')
?: $request->getHeaderLine('Accept-Timezone')
?: null;
if ($tzHeader && in_array($tzHeader, timezone_identifiers_list(), true)) {
return $tzHeader;
}
} catch (\Throwable $e) {
// ignore
}
// 2) User preference (if logged-in)
try {
$userId = (int) (session('user_id') ?: 0);
if ($userId > 0) {
// Do not select a specific column; some DBs may not have it yet
$pref = (new PreferencesModel())
->where('user_id', $userId)
->first();
$tz = $pref['timezone'] ?? null;
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
return $tz;
}
}
} catch (\Throwable $e) {
// ignore
}
// 3) Global settings timezone (if available)
try {
$settings = (new SettingsModel())->getSettings();
$tz = $settings['timezone'] ?? null;
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
return $tz;
}
} catch (\Throwable $e) {
// ignore
}
// 4) School attendance timezone (if defined)
try {
$tz = (string) (config('School')->attendance['timezone'] ?? '');
if ($tz && in_array($tz, timezone_identifiers_list(), true)) {
return $tz;
}
} catch (\Throwable $e) {
// ignore
}
// 5) Default
return $this->defaultUserTimezone;
}
/**
* Server timezone (for storage/UTC operations).
*/
public function serverTimezone(): string
{
return $this->serverTimezone;
}
/**
* Current time in user timezone as CI Time.
*/
public function nowLocal(?string $tz = null): Time
{
$tz = $tz ?: $this->userTimezone();
return Time::now($tz);
}
/**
* Current time in UTC as CI Time.
*/
public function nowUTC(): Time
{
return Time::now($this->serverTimezone);
}
/**
* Convert any supported input to UTC string (Y-m-d H:i:s by default).
*
* @param string|Time|\DateTimeInterface|null $value
*/
public function toUTC($value, ?string $fromTz = null, string $format = 'Y-m-d H:i:s'): ?string
{
if ($value === null || $value === '') {
return null;
}
$fromTz = $fromTz ?: $this->userTimezone();
try {
if ($value instanceof Time) {
return $value->setTimezone($this->serverTimezone)->toDateTimeString();
}
if ($value instanceof \DateTimeInterface) {
return Time::createFromInstance($value)
->setTimezone($this->serverTimezone)
->format($format);
}
// assume string
return Time::parse((string)$value, $fromTz)
->setTimezone($this->serverTimezone)
->format($format);
} catch (\Throwable $e) {
return null;
}
}
/**
* Convert UTC (or provided timezone) to user-local string.
*
* @param string|Time|\DateTimeInterface|null $value
*/
public function toLocal($value, ?string $sourceTz = null, ?string $targetTz = null, string $format = 'Y-m-d H:i:s'): ?string
{
if ($value === null || $value === '') {
return null;
}
$sourceTz = $sourceTz ?: $this->serverTimezone;
$targetTz = $targetTz ?: $this->userTimezone();
try {
if ($value instanceof Time) {
return $value->setTimezone($targetTz)->format($format);
}
if ($value instanceof \DateTimeInterface) {
return Time::createFromInstance($value)
->setTimezone($targetTz)
->format($format);
}
// assume string
return Time::parse((string)$value, $sourceTz)
->setTimezone($targetTz)
->format($format);
} catch (\Throwable $e) {
return null;
}
}
/**
* Convenience formatter for user-local.
*/
public function formatLocal($value, string $format = 'Y-m-d H:i', ?string $sourceTz = null): string
{
return (string) ($this->toLocal($value, $sourceTz, null, $format) ?? '');
}
/**
* Convenience formatter for UTC.
*/
public function formatUTC($value, string $format = 'Y-m-d H:i:s', ?string $fromTz = null): string
{
return (string) ($this->toUTC($value, $fromTz, $format) ?? '');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Services;
use App\Models\UserModel;
class UserEventService
{
public function handleNewAccountAdded(array $userData): void
{
log_message('info', 'New account created: ' . json_encode($userData));
$emailService = new \App\Services\EmailService();
$subject = '🎉 Welcome to Al Rahma Sunday School!';
$message = view('emails/welcome_user', ['user' => $userData]);
$emailService->send(
$userData['email'],
$subject,
$message,
'registration'
);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\UserModel;
class UserService
{
protected $userModel;
public function __construct()
{
$this->userModel = new UserModel();
}
/**
* Get all users with their roles.
*
* @param string $sort Column to sort by
* @param string $order Sorting order (asc/desc)
* @return array List of users with their roles
*/
public function getUsersWithRoles($sort = 'id', $order = 'asc')
{
return $this->userModel->getUsersWithRoles($sort, $order);
}
/**
* Get all unverified users.
*
* @return array List of unverified users
*/
public function getUnverifiedUsers()
{
return $this->userModel->getUnverifiedUsers();
}
/**
* Validate registration input.
*
* @param array $data Input data to validate
* @return array Validation errors
*/
public function validateRegistrationInput(array $data)
{
return $this->userModel->validate_registration_input($data);
}
/**
* Get users by role.
*
* @param string $role Role name
* @return array List of users with the specified role
*/
public function getUsersByRole($role)
{
return $this->userModel->getUsersByRole($role);
}
/**
* Assign a role to a user.
*
* @param int $userId The ID of the user
* @param int $roleId The ID of the role to assign
* @return bool True if the role was successfully assigned
*/
public function assignRole($userId, $roleId)
{
return $this->userModel->assignRole($userId, $roleId);
}
// Additional business logic related to users can be added here
public function cleanupUnverifiedUsers()
{
// Load the UserModel
$userModel = new UserModel();
// Get the current timestamp and subtract 1 hour
//$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$fifteenMinutesAgo = date('Y-m-d H:i:s', strtotime('-15 minutes'));
// Find and delete users where:
// 1. 'is_verified' is false (unverified users)
// 2. 'created_at' is more than 1 hour ago
$builder = $userModel->builder();
$builder->where('is_verified', false)
->where('created_at <=', $fifteenMinutesAgo)
->delete();
// Get the database connection to retrieve affected rows
$db = \Config\Database::connect();
$affectedRows = $db->affectedRows();
// Log the number of affected rows (users deleted)
log_message('info', "Deleted {$affectedRows} unverified users who were created more than 1 hour ago.");
return true;
}
}