recreate project
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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 didn’t 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);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user