diff --git a/app/Config/Format.php b/app/Config/Format.php index 3de98d7..0dac83f 100644 --- a/app/Config/Format.php +++ b/app/Config/Format.php @@ -9,6 +9,7 @@ use CodeIgniter\Format\XMLFormatter; class Format extends BaseConfig { + public int $jsonEncodeDepth = 512; /** * -------------------------------------------------------------------------- * Available Response Formats diff --git a/app/Controllers/View/CertificateController.php b/app/Controllers/View/CertificateController.php index 3446998..cb5d6a8 100644 --- a/app/Controllers/View/CertificateController.php +++ b/app/Controllers/View/CertificateController.php @@ -177,7 +177,10 @@ class CertificateController extends BaseController ->table('certificate_records cr') ->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname') ->join('users u', 'u.id = cr.issued_by', 'left') - ->where('cr.certificate_number', strtoupper($certNumber)) + ->groupStart() + ->where('cr.verification_token', $certNumber) + ->orWhere('cr.certificate_number', strtoupper($certNumber)) + ->groupEnd() ->get()->getRowArray(); return view('certificates/verify', ['record' => $record ?: null]); @@ -210,6 +213,7 @@ class CertificateController extends BaseController 'lastname' => '', 'grade' => (string)($record['grade'] ?? ''), 'cert_number' => (string)($record['certificate_number'] ?? ''), + 'verify_token' => $this->ensureVerificationTokenForRecord($record), ]; // student_name is stored as "Firstname Lastname" — split for the PDF builder @@ -317,24 +321,28 @@ class CertificateController extends BaseController // Load any existing certificates for these students this school year $db = \Config\Database::connect(); $existingCerts = $db->table('certificate_records') - ->select('student_id, certificate_number') + ->select('id, student_id, certificate_number, verification_token') ->whereIn('student_id', array_column($students, 'id')) ->where('school_year', $schoolYear) ->get()->getResultArray(); $existingCertMap = []; foreach ($existingCerts as $ec) { - $existingCertMap[(int)$ec['student_id']] = $ec['certificate_number']; + $existingCertMap[(int)$ec['student_id']] = $ec; } foreach ($students as &$student) { $sid = (int)$student['id']; if (isset($existingCertMap[$sid])) { // Reuse existing certificate number — do not create a new record - $student['cert_number'] = $existingCertMap[$sid]; + $existing = $existingCertMap[$sid]; + $student['cert_number'] = (string)($existing['certificate_number'] ?? ''); + $student['verify_token'] = $this->ensureVerificationTokenForRecord($existing); } else { $certNumber = $this->certRecordModel->nextNumber($schoolYear); + $verifyToken = $this->certRecordModel->generateVerificationToken(); $this->certRecordModel->insert([ 'certificate_number' => $certNumber, + 'verification_token' => $verifyToken, 'student_id' => $sid, 'student_name' => $student['firstname'] . ' ' . $student['lastname'], 'grade' => $this->formatGrade($student['grade'] ?? ''), @@ -345,6 +353,7 @@ class CertificateController extends BaseController 'issued_at' => $issuedAt, ]); $student['cert_number'] = $certNumber; + $student['verify_token'] = $verifyToken; } } unset($student); @@ -396,6 +405,24 @@ class CertificateController extends BaseController return $clean; } + private function ensureVerificationTokenForRecord(array $record): string + { + $token = trim((string)($record['verification_token'] ?? '')); + if ($token !== '') { + return $token; + } + + $recordId = (int)($record['id'] ?? 0); + if ($recordId <= 0) { + return ''; + } + + $token = $this->certRecordModel->generateVerificationToken(); + $this->certRecordModel->update($recordId, ['verification_token' => $token]); + + return $token; + } + // ─── PDF rendering ───────────────────────────────────────────────────────── private function buildPdf(array $students, string $certDate): string @@ -426,9 +453,11 @@ class CertificateController extends BaseController $name = $student['firstname'] . ' ' . $student['lastname']; $grade = $this->formatGrade($student['grade'] ?? ''); $certNumber = $student['cert_number'] ?? ''; + $verifyToken = $student['verify_token'] ?? ''; - // Build verification URL for an inline TCPDF QR code. - $verifyUrl = site_url('verify/' . rawurlencode($certNumber)); + $verifyUrl = $verifyToken !== '' + ? site_url('verify/' . rawurlencode($verifyToken)) + : null; $this->drawCertificate( $pdf, $W, $H, @@ -487,11 +516,11 @@ class CertificateController extends BaseController // ── Student name — center based on actual string width $pdf->SetFont($edwardianFont, '', 38); $nameX = ($W - $pdf->GetStringWidth($name)) / 2; - $this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 229.5); + $this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5); // ── Line under name $pdf->SetFont('times', '', 20); - $pdf->SetXY(0, 243); + $pdf->SetXY(0, 236); $pdf->Cell(600, 20, '___________________________________', 0, 0, 'R'); // ── Description diff --git a/app/Controllers/View/ReportCardsController.php b/app/Controllers/View/ReportCardsController.php index e7a58bc..4dfc0a2 100644 --- a/app/Controllers/View/ReportCardsController.php +++ b/app/Controllers/View/ReportCardsController.php @@ -845,6 +845,7 @@ class ReportCardsController extends PrintablesBaseController $studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? '')); $gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A')); $today = (string)($data['report_date_display'] ?? date('m-d-Y')); + $termRank = trim((string)($data['term_rank_display'] ?? '')); $firstSemScore = $data['first_semester_score'] ?? null; $secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null); $finalAverage = $data['final_average'] ?? null; @@ -980,54 +981,91 @@ class ReportCardsController extends PrintablesBaseController $gradeY = $pdf->GetY(); $gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt - $drawGradeCell = static function (\FPDF $pdf, float $x, float $y, float $w, float $h, string $label, string $value, float $pad) { - $pdf->Rect($x, $y, $w, $h); - $pdf->SetXY($x + $pad, $y + 3); - $pdf->SetFont('Helvetica', 'B', 11); - $pdf->Write(5, $label . ' '); - $labelWidth = $pdf->GetStringWidth($label . ' '); - $pdf->SetFont('Helvetica', '', 12); - $pdf->SetXY($x + 2 + $labelWidth, $y + 3); - $pdf->Write(5, $value); - }; + $drawGradeCell = static function ( + \FPDF $pdf, + float $x, + float $y, + float $w, + float $h, + string $label, + string $value, + float $pad +) { + $pdf->Rect($x, $y, $w, $h); + $pdf->SetXY($x + $pad, $y + 3); + $pdf->SetFont('Helvetica', 'B', 11); + $pdf->Write(5, $label . ' '); + $labelWidth = $pdf->GetStringWidth($label . ' '); + $pdf->SetFont('Helvetica', '', 12); + $pdf->SetXY($x + 2 + $labelWidth, $y + 3); + $pdf->Write(5, $value); +}; +$drawRankCell = static function ( + \FPDF $pdf, + float $x, + float $y, + float $w, + float $h, + string $rankValue, + float $pad +) { + $rankValue = trim($rankValue) !== '' ? trim($rankValue) : 'N/A'; - if ($semNum === 2) { - $firstLabel = ' 1st Semester Grade:'; - $firstValue = $numFmt($firstSemScore) . '/100'; - $secondLabel = ' 2nd Semester Grade:'; - $secondValue = $numFmt($effectiveSecond) . '/100'; - $drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad); - $drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad); - } else { - $gradeLabel = ' 1st Semester Grade:'; - $gradeValue = $numFmt($effectiveSecond) . '/100'; - $drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad); - } - $pdf->SetY($gradeY + $gradeCellHeight); + $pdf->Rect($x, $y, $w, $h); + $pdf->SetXY($x + $pad, $y + 3); + $pdf->SetFont('Helvetica', 'B', 11); + $pdf->Write(5, ' Ranking: '); - $finalScoreRowHeight = 12; - $finalScoreEndY = null; - // Show Final Score only for second semester / Spring - if ($semNum === 2) { - $finalLabel = 'Final Score****:'; - $finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100'; - $finalCellWidth = 65; // match Total Semester Days column width - $finalCellHeight = $finalScoreRowHeight; - $finalX = $pdf->GetX(); - $finalY = $pdf->GetY(); - $pdf->Rect($finalX, $finalY, $finalCellWidth, $finalCellHeight); - $finalPad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt - $pdf->SetXY($finalX + $finalPad, $finalY + 3); - $pdf->SetFont('Helvetica', 'B', 11); - $pdf->Write(5, ' ' . $finalLabel . ' '); - $finalLabelWidth = $pdf->GetStringWidth(' ' . $finalLabel . ' '); - $pdf->SetFont('Helvetica', '', 12); - $pdf->SetXY($finalX + 2 + $finalLabelWidth, $finalY + 3); - $pdf->Write(5, $finalValue); - $pdf->Ln($finalCellHeight); - $finalScoreEndY = $finalY + $finalCellHeight; - } - $scoresEndY = $pdf->GetY(); + $labelWidth = $pdf->GetStringWidth(' Ranking: '); + $pdf->SetFont('Helvetica', '', 12); + $pdf->SetXY($x + 2 + $labelWidth, $y + 3); + $pdf->Write(5, $rankValue); +}; +if ($semNum === 2) { + $firstLabel = ' 1st Semester Grade:'; + $firstValue = $numFmt($firstSemScore) . '/100'; + + $secondLabel = ' 2nd Semester Grade:'; + $secondValue = $numFmt($effectiveSecond) . '/100'; + + $drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad); + $drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad); +} else { + $gradeLabel = ' 1st Semester Grade:'; + $gradeValue = $numFmt($effectiveSecond) . '/100'; + + $drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad); + + // Fall: Ranking goes to the right of 1st Semester Grade. + $drawRankCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $termRank, $gradePad); +} + +$pdf->SetY($gradeY + $gradeCellHeight); + +$finalScoreRowHeight = 12; +$finalScoreEndY = null; + +// Show Final Score only for second semester / Spring. +if ($semNum === 2) { + $finalLabel = 'Final Score****:'; + $finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100'; + + $finalCellWidth = 65; + $finalCellHeight = $finalScoreRowHeight; + $finalX = $pdf->GetX(); + $finalY = $pdf->GetY(); + $finalPad = max(0, 2 - (4 * 0.3528)); + + $drawGradeCell($pdf, $finalX, $finalY, $finalCellWidth, $finalCellHeight, ' ' . $finalLabel, $finalValue, $finalPad); + + // Spring: Ranking goes to the right of Final Score. + $drawRankCell($pdf, $finalX + $finalCellWidth, $finalY, $finalCellWidth, $finalCellHeight, $termRank, $finalPad); + + $pdf->SetY($finalY + $finalCellHeight); + $finalScoreEndY = $finalY + $finalCellHeight; +} + +$scoresEndY = $pdf->GetY(); // Legend anchored near bottom with a 3mm buffer (dynamic placement) $examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam'; @@ -1546,6 +1584,15 @@ class ReportCardsController extends PrintablesBaseController $firstSemesterScore = $secondSemesterScore; } + $termRanking = $this->calculateTermRanking( + $studentId, + $sectionCode, + $sectionId, + $refYear, + $refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''), + $secondSemesterScore + ); + // Total semester days from attendance records (max total_attendance within same term) $totalSemesterDays = null; @@ -1672,12 +1719,138 @@ class ReportCardsController extends PrintablesBaseController 'second_semester_score' => $secondSemesterScore, 'first_semester_score' => $firstSemesterScore, 'final_average' => $finalAverage, + 'term_rank' => $termRanking, + 'term_rank_display' => $termRanking['display'] ?? null, 'comments' => $commentMap, 'total_score' => $secondSemesterScore, 'total_attendance_days' => $totalSemesterDays, ]; } + private function calculateTermRanking( + int $studentId, + int $sectionCode, + ?int $sectionId, + string $schoolYear, + ?string $semester, + ?float $studentScore + ): ?array { + if ($studentId <= 0 || $schoolYear === '' || $studentScore === null) { + return null; + } + + $sectionIds = array_values(array_unique(array_filter([ + $sectionCode > 0 ? $sectionCode : null, + $sectionId && $sectionId > 0 ? $sectionId : null, + ]))); + + if (empty($sectionIds)) { + return null; + } + + $builder = $this->db->table('semester_scores ss') + ->select('ss.student_id, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname') + ->join('students s', 's.id = ss.student_id', 'inner') + ->where('s.is_active', 1) + ->where('ss.school_year', $schoolYear) + ->whereIn('ss.class_section_id', $sectionIds) + ->orderBy('ss.updated_at', 'DESC') + ->orderBy('ss.id', 'DESC'); + + if (trim((string)$semester) !== '') { + $this->applySemesterFilter($builder, (string)$semester, 'ss.semester'); + } + + $rows = $builder->get()->getResultArray(); + if (empty($rows)) { + return null; + } + + $scoresByStudent = []; + foreach ($rows as $row) { + $sid = (int)($row['student_id'] ?? 0); + if ($sid <= 0 || isset($scoresByStudent[$sid])) { + continue; + } + + $scoreVal = $row['semester_score'] ?? null; + if (!is_numeric($scoreVal)) { + continue; + } + + $scoresByStudent[$sid] = [ + 'student_id' => $sid, + 'score' => round((float)$scoreVal, 4), + 'firstname' => trim((string)($row['firstname'] ?? '')), + 'lastname' => trim((string)($row['lastname'] ?? '')), + ]; + } + + if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) { + return null; + } + + $rankable = array_values($scoresByStudent); + usort($rankable, static function (array $a, array $b): int { + $scoreCmp = $b['score'] <=> $a['score']; + if ($scoreCmp !== 0) { + return $scoreCmp; + } + + $lastCmp = strcasecmp($a['lastname'], $b['lastname']); + if ($lastCmp !== 0) { + return $lastCmp; + } + + $firstCmp = strcasecmp($a['firstname'], $b['firstname']); + if ($firstCmp !== 0) { + return $firstCmp; + } + + return $a['student_id'] <=> $b['student_id']; + }); + + $position = null; + $previousScore = null; + foreach ($rankable as $index => $row) { + if ($previousScore === null || abs($row['score'] - $previousScore) > 0.0001) { + $position = $index + 1; + $previousScore = $row['score']; + } + + if ((int)$row['student_id'] === $studentId) { + $total = count($rankable); + return [ + 'position' => $position, + 'total' => $total, + 'display' => $this->formatOrdinal($position) . ' of ' . $total, + ]; + } + } + + return null; + } + + private function formatOrdinal(?int $value): string + { + $n = (int)$value; + if ($n <= 0) { + return ''; + } + + $mod100 = $n % 100; + if ($mod100 >= 11 && $mod100 <= 13) { + return $n . 'th'; + } + + return match ($n % 10) { + 1 => $n . 'st', + 2 => $n . 'nd', + 3 => $n . 'rd', + default => $n . 'th', + }; + } + /** * Resolve teacher & TA names for a class section (by PK or code), * relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]]. diff --git a/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php b/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php new file mode 100644 index 0000000..51f6c7a --- /dev/null +++ b/app/Database/Migrations/2026-05-27-000001_AddVerificationTokenToCertificateRecords.php @@ -0,0 +1,73 @@ +db->tableExists('certificate_records')) { + return; + } + + if (!$this->db->fieldExists('verification_token', 'certificate_records')) { + $this->forge->addColumn('certificate_records', [ + 'verification_token' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + 'null' => true, + 'after' => 'certificate_number', + ], + ]); + } + + $rows = $this->db->table('certificate_records') + ->select('id, verification_token') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + if (trim((string)($row['verification_token'] ?? '')) !== '') { + continue; + } + + $this->db->table('certificate_records') + ->where('id', (int)$row['id']) + ->update(['verification_token' => $this->generateToken()]); + } + + $this->forge->addUniqueKey('verification_token', self::INDEX_NAME); + $this->forge->processIndexes('certificate_records'); + } + + public function down() + { + if (!$this->db->tableExists('certificate_records')) { + return; + } + + if ($this->db->fieldExists('verification_token', 'certificate_records')) { + $this->forge->dropKey('certificate_records', self::INDEX_NAME); + $this->forge->dropColumn('certificate_records', 'verification_token'); + } + } + + private function generateToken(): string + { + do { + $token = bin2hex(random_bytes(16)); + $exists = $this->db->table('certificate_records') + ->select('id') + ->where('verification_token', $token) + ->limit(1) + ->get() + ->getRowArray(); + } while ($exists); + + return $token; + } +} diff --git a/app/Models/CertificateRecordModel.php b/app/Models/CertificateRecordModel.php index 38633bb..3a5d6b4 100644 --- a/app/Models/CertificateRecordModel.php +++ b/app/Models/CertificateRecordModel.php @@ -12,6 +12,7 @@ class CertificateRecordModel extends Model protected $allowedFields = [ 'certificate_number', + 'verification_token', 'student_id', 'student_name', 'grade', @@ -44,6 +45,23 @@ class CertificateRecordModel extends Model return 'ARSS-' . $schoolYear . '-' . $seq; } + public function generateVerificationToken(): string + { + $db = \Config\Database::connect(); + + do { + $token = bin2hex(random_bytes(16)); + $exists = $db->table($this->table) + ->select('id') + ->where('verification_token', $token) + ->limit(1) + ->get() + ->getRowArray(); + } while ($exists); + + return $token; + } + /** Returns paginated records with the issuing admin's name joined. */ public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..904323d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + mysql: + image: mysql:8.4 + container_name: mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: school + MYSQL_USER: root + MYSQL_PASSWORD: password + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + platform: linux/amd64 + container_name: phpmyadmin + restart: unless-stopped + environment: + PMA_HOST: mysql + PMA_PORT: 3306 + ports: + - "8081:80" + depends_on: + - mysql + +volumes: + mysql_data: \ No newline at end of file