update controllers logic

This commit is contained in:
root
2026-04-23 00:04:35 -04:00
parent 1977a513df
commit ca4ba272fc
353 changed files with 13402 additions and 1301 deletions
@@ -0,0 +1,742 @@
<?php
namespace App\Services\Admin\CompetitionWinners;
use App\Models\ClassSection;
use App\Models\Competition;
use App\Models\CompetitionClassWinner;
use App\Models\CompetitionScore;
use App\Models\CompetitionWinner;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Administrative competition-winner workflows (ported from CodeIgniter {@see \App\Controllers\Admin\CompetitionWinnersController}).
*/
class CompetitionWinnersAdminService
{
protected string $classStudentTable = '';
protected bool $hasClassWinnerTable = false;
protected bool $hasQuestionCount = false;
public function __construct()
{
if (Schema::hasTable('class_student')) {
$this->classStudentTable = 'class_student';
} elseif (Schema::hasTable('student_class')) {
$this->classStudentTable = 'student_class';
}
$this->hasClassWinnerTable = Schema::hasTable('competition_class_winners');
$this->hasQuestionCount = $this->hasClassWinnerTable
&& Schema::hasColumn('competition_class_winners', 'question_count');
}
/** @return array<int, string> */
public function getClassSectionMap(): array
{
$sections = ClassSection::query()
->select('class_section_id', 'class_section_name')
->orderBy('class_section_name', 'ASC')
->get();
$map = [];
foreach ($sections as $section) {
$id = (int) ($section->class_section_id ?? 0);
if ($id > 0) {
$map[$id] = (string) ($section->class_section_name ?? $id);
}
}
return $map;
}
public function getClassSectionName(int $classSectionId): ?string
{
if ($classSectionId <= 0) {
return null;
}
$row = ClassSection::query()
->where('class_section_id', $classSectionId)
->value('class_section_name');
return $row !== null ? (string) $row : null;
}
/** @return array<int, int> */
public function getClassStudentCounts(?string $schoolYear): array
{
if ($this->classStudentTable === '') {
return [];
}
$q = DB::table($this->classStudentTable)
->select('class_section_id', DB::raw('COUNT(*) as total'))
->whereNotNull('class_section_id')
->groupBy('class_section_id');
if ($schoolYear && Schema::hasColumn($this->classStudentTable, 'school_year')) {
$q->where('school_year', $schoolYear);
}
$map = [];
foreach ($q->get() as $row) {
$classId = (int) ($row->class_section_id ?? 0);
if ($classId > 0) {
$map[$classId] = (int) ($row->total ?? 0);
}
}
return $map;
}
/**
* @return array{overrides: array<int,int>, questions: array<int,int>, prizes: array<int, array<int, float|null>>}
*/
public function getClassSettings(int $competitionId): array
{
if (! $this->hasClassWinnerTable) {
return ['overrides' => [], 'questions' => [], 'prizes' => []];
}
$rows = CompetitionClassWinner::query()
->where('competition_id', $competitionId)
->get();
$overrides = [];
$questions = [];
$prizes = [];
foreach ($rows as $row) {
$classId = (int) ($row->class_section_id ?? 0);
if ($classId <= 0) {
continue;
}
if ($row->winners_override !== null) {
$overrides[$classId] = (int) $row->winners_override;
}
if ($this->hasQuestionCount && $row->question_count !== null) {
$questions[$classId] = (int) $row->question_count;
}
$prizes[$classId] = [
1 => CompetitionWinnersDomain::castPrize($row->prize_1 ?? null),
2 => CompetitionWinnersDomain::castPrize($row->prize_2 ?? null),
3 => CompetitionWinnersDomain::castPrize($row->prize_3 ?? null),
4 => CompetitionWinnersDomain::castPrize($row->prize_4 ?? null),
5 => CompetitionWinnersDomain::castPrize($row->prize_5 ?? null),
6 => CompetitionWinnersDomain::castPrize($row->prize_6 ?? null),
];
}
return ['overrides' => $overrides, 'questions' => $questions, 'prizes' => $prizes];
}
/**
* @param array<int|string, mixed> $overrides
* @param array<int|string, mixed> $questionCounts
* @param array<int|string, mixed> $prizes
*/
public function saveClassSettings(
int $competitionId,
array $overrides,
array $questionCounts,
array $prizes,
?int $limitClassId = null
): void {
if (! $this->hasClassWinnerTable) {
return;
}
$classIds = array_unique(array_merge(array_keys($overrides), array_keys($questionCounts), array_keys($prizes)));
foreach ($classIds as $classId) {
$classId = (int) $classId;
if ($classId <= 0) {
continue;
}
if ($limitClassId !== null && $classId !== $limitClassId) {
continue;
}
$rawOverride = trim((string) ($overrides[$classId] ?? ''));
$overrideValue = $rawOverride === '' ? null : (int) $rawOverride;
$rawQuestions = trim((string) ($questionCounts[$classId] ?? ''));
$questionValue = $rawQuestions === '' ? null : (int) $rawQuestions;
$existing = CompetitionClassWinner::query()
->where('competition_id', $competitionId)
->where('class_section_id', $classId)
->first();
$prizeValues = CompetitionWinnersDomain::normalizePrizeInputs((array) ($prizes[$classId] ?? []));
$hasPrize = array_filter($prizeValues, static fn ($v) => $v !== null);
if ($overrideValue === null && $questionValue === null && empty($hasPrize)) {
if ($existing) {
$existing->delete();
}
continue;
}
$payload = [
'competition_id' => $competitionId,
'class_section_id' => $classId,
'winners_override' => $overrideValue,
'prize_1' => $prizeValues[1],
'prize_2' => $prizeValues[2],
'prize_3' => $prizeValues[3],
'prize_4' => $prizeValues[4],
'prize_5' => $prizeValues[5],
'prize_6' => $prizeValues[6],
];
if ($this->hasQuestionCount) {
$payload['question_count'] = $questionValue;
}
if ($existing) {
$existing->fill($payload);
$existing->save();
} else {
CompetitionClassWinner::query()->create($payload);
}
}
}
/**
* @param array<string, mixed> $competition
* @return list<array<string, mixed>>
*/
public function getStudentsForCompetition(array $competition, int $classSectionId): array
{
if ($classSectionId <= 0 || $this->classStudentTable === '') {
return [];
}
$q = DB::table('students as s')
->select('s.id', 's.school_id', 's.firstname', 's.lastname')
->join($this->classStudentTable.' as cs', 'cs.student_id', '=', 's.id')
->where('cs.class_section_id', $classSectionId);
if (Schema::hasColumn($this->classStudentTable, 'school_year') && ! empty($competition['school_year'])) {
$q->where('cs.school_year', $competition['school_year']);
}
return $q->distinct()->orderBy('s.lastname')->orderBy('s.firstname')->get()->map(fn ($r) => (array) $r)->all();
}
/** @return array<int, int> */
public function getQuestionCountsForCompetition(int $competitionId): array
{
if (! $this->hasQuestionCount || ! $this->hasClassWinnerTable) {
return [];
}
$rows = CompetitionClassWinner::query()
->where('competition_id', $competitionId)
->whereNotNull('question_count')
->get(['class_section_id', 'question_count']);
$out = [];
foreach ($rows as $row) {
$classId = (int) ($row->class_section_id ?? 0);
if ($classId > 0 && $row->question_count !== null) {
$out[$classId] = (int) $row->question_count;
}
}
return $out;
}
/** @return array{competitions: \Illuminate\Support\Collection, sectionMap: array<int, string>} */
public function indexData(): array
{
$competitions = Competition::query()->orderByDesc('id')->get();
$sectionMap = $this->getClassSectionMap();
return ['competitions' => $competitions, 'sectionMap' => $sectionMap];
}
/** @return array<string, mixed> */
public function createFormData(): array
{
$schoolYear = Configuration::getConfig('school_year');
$classCounts = $this->getClassStudentCounts($schoolYear);
$classSections = ClassSection::query()->orderBy('class_section_name')->get()->map(fn ($r) => $r->toArray())->all();
$classSections = CompetitionWinnersDomain::filterSectionsWithStudents($classSections, $classCounts);
$classRows = CompetitionWinnersDomain::buildClassRows($classSections, $classCounts, [], [], []);
return [
'mode' => 'create',
'competition' => null,
'classSections' => $classSections,
'classRows' => $classRows,
'defaultSemester' => Configuration::getConfig('semester'),
'defaultSchoolYear' => $schoolYear,
];
}
/** @return array<string, mixed>|null */
public function settingsFormData(int $id): ?array
{
$competition = Competition::query()->find($id);
if (! $competition) {
return null;
}
$row = $competition->toArray();
$schoolYear = $row['school_year'] ?? Configuration::getConfig('school_year');
$classCounts = $this->getClassStudentCounts($schoolYear);
$classSections = ClassSection::query()->orderBy('class_section_name')->get()->map(fn ($r) => $r->toArray())->all();
$classSections = CompetitionWinnersDomain::filterSectionsWithStudents($classSections, $classCounts);
$settings = $this->getClassSettings((int) $id);
$limitClassId = (int) ($row['class_section_id'] ?? 0);
$classRows = CompetitionWinnersDomain::buildClassRows(
$classSections,
$classCounts,
$settings['overrides'],
$settings['questions'],
$settings['prizes'],
$limitClassId
);
return [
'mode' => 'edit',
'competition' => $row,
'classSections' => $classSections,
'classRows' => $classRows,
'defaultSemester' => Configuration::getConfig('semester'),
'defaultSchoolYear' => $schoolYear,
];
}
/**
* @param array<string, mixed> $validated
*/
public function storeCompetition(array $validated, array $overrides, array $questionCounts, array $prizes, ?int $actorId): int
{
$classSectionId = $validated['class_section_id'] ?? null;
$classSectionId = $classSectionId !== '' && $classSectionId !== null ? (int) $classSectionId : null;
$competition = Competition::query()->create([
'title' => $validated['title'],
'semester' => $validated['semester'] ?? null,
'school_year' => $validated['school_year'] ?? null,
'class_section_id' => $classSectionId,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'created_by' => $actorId,
]);
$this->saveClassSettings((int) $competition->id, $overrides, $questionCounts, $prizes, $classSectionId);
return (int) $competition->id;
}
/**
* @param array<string, mixed> $validated
*/
public function updateCompetition(int $id, array $validated, array $overrides, array $questionCounts, array $prizes): bool
{
$competition = Competition::query()->find($id);
if (! $competition) {
return false;
}
$classSectionId = $validated['class_section_id'] ?? null;
$classSectionId = $classSectionId !== '' && $classSectionId !== null ? (int) $classSectionId : null;
$competition->update([
'title' => $validated['title'],
'semester' => $validated['semester'] ?? null,
'school_year' => $validated['school_year'] ?? null,
'class_section_id' => $classSectionId,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
]);
$this->saveClassSettings($id, $overrides, $questionCounts, $prizes, $classSectionId);
return true;
}
/** @return array<string, mixed>|null */
public function editScoresData(int $id, ?int $queryClassSectionId): ?array
{
$competition = Competition::query()->find($id);
if (! $competition) {
return null;
}
$c = $competition->toArray();
$lockedClassSectionId = (int) ($c['class_section_id'] ?? 0);
$selectedClassSectionId = (int) ($queryClassSectionId ?? 0);
$activeClassSectionId = $lockedClassSectionId > 0 ? $lockedClassSectionId : $selectedClassSectionId;
$students = [];
$scoreMap = [];
if ($activeClassSectionId > 0) {
$students = $this->getStudentsForCompetition($c, $activeClassSectionId);
$existingScores = CompetitionScore::query()
->where('competition_id', $id)
->where('class_section_id', $activeClassSectionId)
->get();
foreach ($existingScores as $row) {
$scoreMap[(int) $row->student_id] = $row->score;
}
}
$schoolYear = $c['school_year'] ?? null;
$classCounts = $this->getClassStudentCounts($schoolYear);
$classSections = ClassSection::query()->orderBy('class_section_name')->get()->map(fn ($r) => $r->toArray())->all();
$classSections = CompetitionWinnersDomain::filterSectionsWithStudents($classSections, $classCounts);
$settings = $this->getClassSettings((int) $id);
$classStudentCount = $classCounts[$activeClassSectionId] ?? 0;
$classOverride = $settings['overrides'][$activeClassSectionId] ?? null;
$classAuto = CompetitionWinnersDomain::winnersForCount($classStudentCount);
$classFinal = $classOverride !== null ? $classOverride : $classAuto;
$classQuestionCount = $settings['questions'][$activeClassSectionId] ?? null;
return [
'competition' => $c,
'students' => $students,
'scoreMap' => $scoreMap,
'classSections' => $classSections,
'activeClassSectionId' => $activeClassSectionId,
'classSectionName' => $this->getClassSectionName($activeClassSectionId),
'classSelectionLocked' => $lockedClassSectionId > 0,
'classStudentCount' => $classStudentCount,
'classAutoWinners' => $classAuto,
'classOverrideWinners' => $classOverride,
'classFinalWinners' => $classFinal,
'classQuestionCount' => $classQuestionCount,
'classPrizeMap' => $settings['prizes'][$activeClassSectionId] ?? [],
];
}
/** @param array<int|string, mixed> $scores */
public function saveScores(int $competitionId, array $scores, ?int $postClassSectionId, array $competitionRow): bool
{
$classSectionId = (int) ($competitionRow['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
$classSectionId = (int) ($postClassSectionId ?? 0);
}
if ($classSectionId <= 0) {
return false;
}
foreach ($scores as $studentId => $scoreValue) {
$scoreValue = trim((string) $scoreValue);
if ($scoreValue === '') {
continue;
}
$scoreFloat = (float) $scoreValue;
$existing = CompetitionScore::query()
->where('competition_id', $competitionId)
->where('student_id', (int) $studentId)
->where('class_section_id', $classSectionId)
->first();
if ($existing) {
$existing->score = $scoreFloat;
$existing->class_section_id = $classSectionId;
$existing->save();
} else {
CompetitionScore::query()->create([
'competition_id' => $competitionId,
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
'score' => $scoreFloat,
]);
}
}
return true;
}
/** @return array<string, mixed>|null */
public function previewData(int $id): ?array
{
$competition = Competition::query()->find($id);
if (! $competition) {
return null;
}
$c = $competition->toArray();
$rows = DB::table('competition_scores as cs')
->leftJoin('students as s', 's.id', '=', 'cs.student_id')
->select('cs.*', 's.firstname', 's.lastname')
->where('cs.competition_id', $id)
->get()
->map(fn ($r) => (array) $r)
->all();
$rankedByClass = CompetitionWinnersDomain::groupScoresByClass($rows);
$scoreCounts = CompetitionWinnersDomain::getScoreCountsByClass($rankedByClass);
$classCounts = $this->getClassStudentCounts($c['school_year'] ?? null);
$settings = $this->getClassSettings((int) $id);
$winnersCountByClass = CompetitionWinnersDomain::getWinnersCountByClass(
$c,
$classCounts,
$scoreCounts,
$settings['overrides']
);
$topByClass = CompetitionWinnersDomain::getTopWinnersByClass(
$rankedByClass,
$winnersCountByClass,
$settings['prizes'],
$settings['questions']
);
return [
'competition' => $c,
'classMap' => $this->getClassSectionMap(),
'classCounts' => $classCounts,
'overrideMap' => $settings['overrides'],
'questionCounts' => $settings['questions'],
'prizeMap' => $settings['prizes'],
'winnersCountByClass' => $winnersCountByClass,
'rankedByClass' => $rankedByClass,
'topByClass' => $topByClass,
];
}
/** @return list<array<string,mixed>> */
public function winnersListingRows(int $competitionId): array
{
return DB::table('competition_winners as cw')
->selectRaw('cw.*, s.firstname, s.lastname, s.school_id, s.photo_consent, cs.class_section_name')
->leftJoin('students as s', 's.id', '=', 'cw.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'cw.class_section_id')
->where('cw.competition_id', $competitionId)
->orderBy('cw.class_section_id')
->orderBy('cw.rank')
->get()
->map(fn ($r) => (array) $r)
->all();
}
public function publishWinners(int $id): bool
{
$competition = Competition::query()->find($id);
if (! $competition) {
return false;
}
$c = $competition->toArray();
$rows = CompetitionScore::query()->where('competition_id', $id)->get()->map(fn ($r) => $r->toArray())->all();
$rankedByClass = CompetitionWinnersDomain::groupScoresByClass($rows);
$scoreCounts = CompetitionWinnersDomain::getScoreCountsByClass($rankedByClass);
$classCounts = $this->getClassStudentCounts($c['school_year'] ?? null);
$settings = $this->getClassSettings((int) $id);
$winnersCountByClass = CompetitionWinnersDomain::getWinnersCountByClass(
$c,
$classCounts,
$scoreCounts,
$settings['overrides']
);
CompetitionWinner::query()->where('competition_id', $id)->delete();
foreach ($rankedByClass as $classSectionId => $rowsForClass) {
$limit = (int) ($winnersCountByClass[$classSectionId] ?? 0);
if ($limit <= 0) {
continue;
}
$topWithPrizes = CompetitionWinnersDomain::assignPrizesByScore(
$rowsForClass,
$settings['prizes'][$classSectionId] ?? [],
$limit,
$settings['questions'][$classSectionId] ?? null
);
foreach ($topWithPrizes as $row) {
CompetitionWinner::query()->create([
'competition_id' => (int) $id,
'student_id' => (int) $row['student_id'],
'class_section_id' => (int) $classSectionId,
'rank' => (int) $row['rank'],
'score' => (float) $row['score'],
'prize_amount' => (float) $row['prize_amount'],
'created_at' => now()->toDateTimeString(),
]);
}
}
$competition->update([
'is_published' => 1,
'published_at' => now()->toDateTimeString(),
]);
return true;
}
public function lockCompetition(int $id, ?int $userId): bool
{
$competition = Competition::query()->find($id);
if (! $competition) {
return false;
}
if ($competition->is_locked) {
return true;
}
$competition->update([
'is_locked' => 1,
'locked_at' => now()->toDateTimeString(),
'locked_by' => $userId,
]);
return true;
}
public function unlockCompetition(int $id): bool
{
$competition = Competition::query()->find($id);
if (! $competition) {
return false;
}
if (! $competition->is_locked) {
return true;
}
$competition->update([
'is_locked' => 0,
'locked_at' => null,
'locked_by' => null,
]);
return true;
}
/**
* Export competition scores into quiz rows (SQL preserved from CodeIgniter).
*
* @return array{ok: bool, message: string, quizIndexes?: array<int,int>}
*/
public function exportCompetitionToQuiz(int $competitionId, int $classSectionId, ?int $schoolId, ?int $userId, ?string $semester, ?string $schoolYear): array
{
if ($competitionId <= 0) {
return ['ok' => false, 'message' => 'Competition not found.'];
}
$competition = Competition::query()->find($competitionId);
if (! $competition) {
return ['ok' => false, 'message' => 'Competition not found.'];
}
$lockedClassSectionId = (int) ($competition->class_section_id ?? 0);
if ($lockedClassSectionId > 0) {
$classSectionId = $lockedClassSectionId;
}
if ($classSectionId > 0 && $lockedClassSectionId > 0 && $lockedClassSectionId !== $classSectionId) {
return ['ok' => false, 'message' => 'This competition is locked to a different class section.'];
}
$semester = $semester ?: Configuration::getConfig('semester');
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
$classSectionIds = [];
if ($classSectionId > 0) {
$classSectionIds = [$classSectionId];
} else {
$rows = DB::table('competition_scores')
->select('class_section_id')
->distinct()
->where('competition_id', $competitionId)
->where('class_section_id', '>', 0)
->get();
foreach ($rows as $row) {
$cid = (int) ($row->class_section_id ?? 0);
if ($cid > 0) {
$classSectionIds[] = $cid;
}
}
}
if ($classSectionIds === []) {
return ['ok' => false, 'message' => 'No class sections with scores found for this competition.'];
}
$quizIndexes = [];
DB::transaction(function () use ($competitionId, $classSectionIds, $schoolId, $userId, $semester, $schoolYear, &$quizIndexes): void {
foreach ($classSectionIds as $cid) {
$cid = (int) $cid;
if ($cid <= 0) {
continue;
}
$row = DB::selectOne(
'SELECT COALESCE(MAX(quiz_index), 0) + 1 AS next_index
FROM quiz
WHERE class_section_id = ?
AND semester = ?
AND school_year = ?',
[$cid, $semester, $schoolYear]
);
$quizIndex = (int) ($row->next_index ?? 1);
$sql = <<<'SQL'
INSERT INTO quiz
(student_id, school_id, class_section_id, updated_by, quiz_index, score, comment, semester, school_year, created_at, updated_at)
SELECT
cs.student_id,
?,
cs.class_section_id,
?,
?,
ROUND(LEAST(100, GREATEST(0, (cs.score / NULLIF(ccw.question_count, 0)) * 100)), 2),
CONCAT('Competition ', cs.competition_id, ' normalized from ', cs.score, '/', ccw.question_count),
?,
?,
NOW(), NOW()
FROM competition_scores cs
JOIN competition_class_winners ccw
ON ccw.competition_id = cs.competition_id
AND ccw.class_section_id = cs.class_section_id
WHERE cs.competition_id = ?
AND cs.class_section_id = ?
SQL;
DB::statement($sql, [
$schoolId,
$userId,
$quizIndex,
$semester,
$schoolYear,
$competitionId,
$cid,
]);
$quizIndexes[$cid] = $quizIndex;
}
});
if ($quizIndexes === []) {
return ['ok' => false, 'message' => 'No scores were exported.'];
}
return ['ok' => true, 'message' => 'Exported.', 'quizIndexes' => $quizIndexes];
}
}
@@ -0,0 +1,345 @@
<?php
namespace App\Services\Admin\CompetitionWinners;
/** Pure domain logic ported from CodeIgniter `Admin\CompetitionWinnersController` (private helpers). */
final class CompetitionWinnersDomain
{
/** @var array<int, array{min:int, max:?int, winners:int}> */
private const WINNER_TIERS = [
['min' => 1, 'max' => 12, 'winners' => 2],
['min' => 13, 'max' => 16, 'winners' => 3],
['min' => 17, 'max' => 22, 'winners' => 4],
['min' => 23, 'max' => 27, 'winners' => 5],
['min' => 28, 'max' => null, 'winners' => 6],
];
public static function winnersForCount(int $count): int
{
if ($count <= 0) {
return 0;
}
foreach (self::WINNER_TIERS as $tier) {
$min = (int) $tier['min'];
$max = $tier['max'];
if ($count < $min) {
continue;
}
if ($max === null || $count <= (int) $max) {
return (int) $tier['winners'];
}
}
return 0;
}
/**
* @param array<int, array<string, mixed>> $classSections
* @param array<int, int> $classCounts
* @param array<int, int> $overrides
* @param array<int, int|null> $questionCounts
* @param array<int, array<int, float|null>> $prizeMap
* @return list<array<string, mixed>>
*/
public static function buildClassRows(
array $classSections,
array $classCounts,
array $overrides,
array $questionCounts,
array $prizeMap,
int $limitClassId = 0
): array {
$rows = [];
foreach ($classSections as $section) {
$classId = (int) ($section['class_section_id'] ?? 0);
if ($classId <= 0) {
continue;
}
if ($limitClassId > 0 && $classId !== $limitClassId) {
continue;
}
$count = $classCounts[$classId] ?? 0;
if ($count <= 0) {
continue;
}
$auto = self::winnersForCount($count);
$override = isset($overrides[$classId]) ? (int) $overrides[$classId] : null;
$questionCount = isset($questionCounts[$classId]) ? (int) $questionCounts[$classId] : null;
$prizes = $prizeMap[$classId] ?? [];
$rows[] = [
'class_section_id' => $classId,
'class_section_name' => $section['class_section_name'] ?? (string) $classId,
'student_count' => $count,
'auto_winners' => $auto,
'override_winners' => $override,
'final_winners' => $override !== null ? $override : $auto,
'question_count' => $questionCount,
'prize_1' => $prizes[1] ?? null,
'prize_2' => $prizes[2] ?? null,
'prize_3' => $prizes[3] ?? null,
'prize_4' => $prizes[4] ?? null,
'prize_5' => $prizes[5] ?? null,
'prize_6' => $prizes[6] ?? null,
];
}
return $rows;
}
/**
* @param array<int, array<string, mixed>> $classSections
* @param array<int, int> $classCounts
* @return array<int, array<string, mixed>>
*/
public static function filterSectionsWithStudents(array $classSections, array $classCounts): array
{
return array_values(array_filter($classSections, static function ($section) use ($classCounts) {
$classId = (int) ($section['class_section_id'] ?? 0);
if ($classId <= 0) {
return false;
}
return ($classCounts[$classId] ?? 0) > 0;
}));
}
/**
* @param array<string, mixed> $competition Row including optional class_section_id
* @param array<int, int> $classCounts
* @param array<int, int> $scoreCounts
* @param array<int, int> $overrides
* @return array<int, int>
*/
public static function getWinnersCountByClass(
array $competition,
array $classCounts,
array $scoreCounts,
array $overrides
): array {
$classIds = array_unique(array_merge(
array_keys($classCounts),
array_keys($scoreCounts),
array_keys($overrides)
));
$limitClassId = (int) ($competition['class_section_id'] ?? 0);
$map = [];
foreach ($classIds as $classId) {
$classId = (int) $classId;
if ($classId <= 0) {
continue;
}
if ($limitClassId > 0 && $classId !== $limitClassId) {
continue;
}
$override = $overrides[$classId] ?? null;
if ($override !== null) {
$map[$classId] = (int) $override;
continue;
}
$baseCount = $classCounts[$classId] ?? ($scoreCounts[$classId] ?? 0);
if ($baseCount <= 0) {
$map[$classId] = 0;
continue;
}
$maxWinners = self::winnersForCount((int) $baseCount);
$minWinners = 3;
$map[$classId] = $maxWinners >= $minWinners ? $maxWinners : $minWinners;
}
return $map;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, list<array<string, mixed>>>
*/
public static function groupScoresByClass(array $rows): array
{
$grouped = [];
foreach ($rows as $row) {
$classSectionId = (int) ($row['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
continue;
}
$name = trim(($row['firstname'] ?? '').' '.($row['lastname'] ?? ''));
$score = (float) ($row['score'] ?? 0);
$grouped[$classSectionId][] = [
'student_id' => $row['student_id'],
'name' => $name !== '' ? $name : ('Student #'.$row['student_id']),
'score' => $score,
];
}
foreach ($grouped as &$rowsForClass) {
usort($rowsForClass, static function ($a, $b) {
return $b['score'] <=> $a['score'];
});
}
unset($rowsForClass);
return $grouped;
}
/**
* @param array<int, list<array<string, mixed>>> $rankedByClass
* @return array<int, int>
*/
public static function getScoreCountsByClass(array $rankedByClass): array
{
$counts = [];
foreach ($rankedByClass as $classId => $rows) {
$counts[(int) $classId] = count($rows);
}
return $counts;
}
/**
* @param array<int, list<array<string, mixed>>> $rankedByClass
* @param array<int, int> $winnersCountByClass
* @param array<int, array<int, float|null>> $prizeMap
* @param array<int, int|null> $questionCounts
* @return array<int, list<array<string, mixed>>>
*/
public static function getTopWinnersByClass(
array $rankedByClass,
array $winnersCountByClass,
array $prizeMap,
array $questionCounts
): array {
$out = [];
foreach ($rankedByClass as $classSectionId => $rows) {
$limit = (int) ($winnersCountByClass[$classSectionId] ?? 0);
if ($limit <= 0) {
continue;
}
$out[$classSectionId] = self::assignPrizesByScore(
$rows,
$prizeMap[$classSectionId] ?? [],
$limit,
$questionCounts[$classSectionId] ?? null
);
}
return $out;
}
/**
* @param array<int, mixed> $prizes
* @return array<int, float|null>
*/
public static function normalizePrizeInputs(array $prizes): array
{
$out = [1 => null, 2 => null, 3 => null, 4 => null, 5 => null, 6 => null];
foreach ($out as $rank => $_value) {
$raw = trim((string) ($prizes[$rank] ?? ''));
if ($raw === '') {
$out[$rank] = null;
} else {
$out[$rank] = (float) $raw;
}
}
return $out;
}
public static function castPrize(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
return (float) $value;
}
/**
* @param array<int, float|null> $classPrizes
*/
public static function getPrizeForRank(array $classPrizes, int $rank): float
{
$val = $classPrizes[$rank] ?? null;
return $val !== null ? (float) $val : 0.0;
}
/**
* @param list<array<string, mixed>> $rows
* @param array<int, float|null> $classPrizes
* @return list<array<string, mixed>>
*/
public static function assignPrizesByScore(array $rows, array $classPrizes, int $limit, ?int $questionCount = null): array
{
if ($limit <= 0) {
return [];
}
usort($rows, static function ($a, $b) {
return ($b['score'] ?? 0) <=> ($a['score'] ?? 0);
});
$scoreGroups = [];
$scoreOrder = [];
foreach ($rows as $row) {
$score = (float) ($row['score'] ?? 0);
$scoreKey = number_format($score, 5, '.', '');
if (! array_key_exists($scoreKey, $scoreGroups)) {
$scoreGroups[$scoreKey] = [];
$scoreOrder[] = $scoreKey;
}
$scoreGroups[$scoreKey][] = $row;
}
$top = [];
$rank = 1;
foreach ($scoreOrder as $scoreKey) {
if (count($top) >= $limit) {
break;
}
foreach ($scoreGroups[$scoreKey] as $row) {
$row['rank'] = $rank;
$top[] = $row;
}
$rank++;
}
$sharedPrize = null;
if ($questionCount !== null && $questionCount > 0 && ! empty($top)) {
$allMax = true;
foreach ($top as $row) {
$score = (float) ($row['score'] ?? 0);
if ($score < $questionCount) {
$allMax = false;
break;
}
}
if ($allMax) {
$sharedPrize = self::getPrizeForRank($classPrizes, 1);
}
}
$out = [];
foreach ($top as $row) {
if ($sharedPrize !== null) {
$prize = $sharedPrize;
} else {
$prize = self::getPrizeForRank($classPrizes, (int) ($row['rank'] ?? 1));
}
$row['prize_amount'] = $prize;
$out[] = $row;
}
return $out;
}
}
@@ -2,6 +2,7 @@
namespace App\Services\Administrator;
use App\Services\ApplicationUrlService;
use App\Services\Staff\StaffTimeOffLinkService;
use App\Models\StaffAttendance;
use App\Models\User;
@@ -18,6 +19,7 @@ class AdministratorAbsenceService
protected StaffAttendance $staffAttendanceModel,
protected SemesterRangeService $semesterRangeService,
protected StaffTimeOffLinkService $staffTimeOffLinkService,
protected ApplicationUrlService $urls,
) {
}
@@ -209,7 +211,7 @@ class AdministratorAbsenceService
'origin' => 'administrator portal',
]);
$notifyUrl = url('/timeoff/notify/' . rawurlencode($token));
$notifyUrl = $this->urls->timeoffNotifyUrl($token);
$body .= '<p style="margin-top:16px;">Click <a href="' . e($notifyUrl) . '">Send confirmation email to '
. e($fullName)
@@ -3,10 +3,16 @@
namespace App\Services\Administrator;
use App\Models\Invoice;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\Event;
class AdministratorEnrollmentEventService
{
public function __construct(
private ApplicationUrlService $urls,
) {
}
public function dispatchGroupedEvents(
array $groupsByParentStatus,
array $parentInfo,
@@ -57,7 +63,7 @@ class AdministratorEnrollmentEventService
'firstname' => $p['firstname'],
'lastname' => $p['lastname'],
'school_year' => $schoolYear,
'portalLink' => url('/login'),
'portalLink' => $this->urls->webLoginUrl(),
];
if ($status === 'payment pending' && $invoice) {
@@ -3,6 +3,7 @@
namespace App\Services\Administrator;
use App\Mail\TeacherSubmissionReminderMail;
use App\Services\ApplicationUrlService;
use App\Models\ClassSection;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User;
@@ -14,7 +15,8 @@ class TeacherSubmissionNotificationService
{
public function __construct(
protected AdministratorSharedService $shared,
protected TeacherSubmissionSupportService $support
protected TeacherSubmissionSupportService $support,
protected ApplicationUrlService $urls,
) {
}
@@ -58,7 +60,7 @@ class TeacherSubmissionNotificationService
$adminUser = User::find($adminId);
$adminName = trim(($adminUser->firstname ?? '') . ' ' . ($adminUser->lastname ?? '')) ?: 'Administrator';
$scoreUrl = url('/');
$scoreUrl = $this->urls->docsHomeUrl();
$sentCount = 0;
$failCount = 0;
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace App\Services;
/**
* Laravel named routes (`route()`), cookie-session URLs, SPA paths, and absolute URLs for `public/` paths.
*/
final class ApplicationUrlService
{
/** Absolute URL for a path under `public/` (e.g. `/docs/openapi_*.yaml`). */
public function absolutePublicPathUrl(string $path): string
{
$path = trim($path);
if ($path === '') {
return url('/');
}
if (! str_starts_with($path, '/')) {
$path = '/'.$path;
}
return url($path);
}
/** Named route `docs.swagger.catalog`. */
public function docsSwaggerCatalogUrl(bool $absolute = true): string
{
return route('docs.swagger.catalog', [], $absolute);
}
/** Named route `docs.swagger-json`; SPA bootstrap often passes `$absolute = false`. */
public function docsSwaggerMergedJsonUrl(bool $absolute = true): string
{
return route('docs.swagger-json', [], $absolute);
}
/** Named route `api-docs.public`. */
public function apiDocsPublicBootstrapUrl(bool $absolute = true): string
{
return route('api-docs.public', [], $absolute);
}
/** Named route `api-docs.index`. */
public function apiDocsAdminBootstrapUrl(bool $absolute = true): string
{
return route('api-docs.index', [], $absolute);
}
/** Named route `docs.home` (GET `/`; usually redirects to the docs client). */
public function docsHomeUrl(bool $absolute = true): string
{
return route('docs.home', [], $absolute);
}
/** Cookie-session SPA paths from `routes/web.php`. Named route `login`. */
public function webLoginUrl(bool $absolute = true): string
{
return route('login', [], $absolute);
}
/** Named route `auth.session.logout`. */
public function webLogoutUrl(bool $absolute = true): string
{
return route('auth.session.logout', [], $absolute);
}
/** Named route `auth.session.ping` (POST). */
public function webSessionPingUrl(bool $absolute = true): string
{
return route('auth.session.ping', [], $absolute);
}
/** Named route `auth.session.check-timeout`. */
public function webSessionCheckTimeoutUrl(bool $absolute = true): string
{
return route('auth.session.check-timeout', [], $absolute);
}
/** Named route `auth.session.select-role` (GET). */
public function webSelectRoleUrl(bool $absolute = true): string
{
return route('auth.session.select-role', [], $absolute);
}
/** SPA registration confirmation email (`/user/confirm/{token}`). */
public function spaRegistrationConfirmUrl(string $token): string
{
return url('/user/confirm/'.$token);
}
/** Parent portal invoice (`parent/invoices/{id}`). */
public function spaParentInvoiceDetailUrl(int|string $invoiceId): string
{
return url('parent/invoices/'.$invoiceId);
}
/** Parent portal invoice view (`parent/invoices/view/{id}`). */
public function spaParentInvoiceViewUrl(int|string $invoiceId): string
{
return url('parent/invoices/view/'.$invoiceId);
}
public function spaParentInvoicesIndexUrl(): string
{
return url('parent/invoices');
}
public function spaAdminPrintRequestsUrl(): string
{
return url('/admin/print-requests');
}
public function spaAdministratorCalendarViewUrl(): string
{
return url('/administrator/calendar_view');
}
public function inviteConfirmUrl(string $plainToken): string
{
return route('api.invite.confirm', ['token' => $plainToken]);
}
public function inviteSetPasswordFormUrl(int $authorizedUserId, string $token): string
{
return route('api.invite.set-password-form', [
'authorizedUserId' => $authorizedUserId,
'token' => $token,
]);
}
public function timeoffNotifyUrl(string $token): string
{
return route('api.timeoff.notify', ['token' => $token]);
}
/** Served by {@see \App\Http\Controllers\Api\Reports\FilesController::receipt} (uploads/receipts). */
public function forReceiptStorage(?string $filename): ?string
{
if (!$filename) {
return null;
}
$safe = basename(trim($filename));
return $safe !== '' ? route('api.v1.files.receipt', ['name' => $safe]) : null;
}
/** Served by {@see \App\Http\Controllers\Api\Reports\FilesController::reimb} (uploads/reimbursements). */
public function forReimbursementStorage(?string $filename): ?string
{
if (!$filename) {
return null;
}
$safe = basename(trim($filename));
return $safe !== '' ? route('api.v1.files.reimbursement', ['name' => $safe]) : null;
}
public function forReimbursementAdminCheckFile(string $filename, string $mode = 'inline'): string
{
return route('api.v1.finance.reimbursements.admin-file', [
'name' => $filename,
'mode' => $mode,
]);
}
public function paypalExecuteReturnUrl(): string
{
return route('api.v1.finance.paypal.execute');
}
public function paypalCancelUrl(): string
{
return route('api.v1.finance.paypal.cancel');
}
public function studentReportCardUrl(int $studentId): string
{
return route('api.v1.students.report-card', ['studentId' => $studentId]);
}
public function forClassProgressAttachment(int|string $attachmentId): string
{
return route('api.v1.class-progress.attachment', ['attachment' => $attachmentId]);
}
/**
* @return array{template_endpoint: string, list_endpoint: string, rest_prefix: string, list_data_endpoint: string}
*/
public function attendanceCommentTemplateBootstrapData(): array
{
return [
'template_endpoint' => route('api.v1.attendance-templates.legacy'),
'list_endpoint' => route('api.v1.attendance-templates.legacy'),
'rest_prefix' => route('api.v1.attendance-comment-templates.index'),
'list_data_endpoint' => route('api.v1.attendance-comment-templates.list-data'),
];
}
}
@@ -52,7 +52,7 @@ class AttendanceConsequenceService
string $type,
array $payload,
string $defaultSubject,
string $viewName,
string $_viewName,
string $logLevel
): array {
$studentName = trim((string) ($payload['student']['firstname'] ?? '') . ' ' . (string) ($payload['student']['lastname'] ?? ''));
@@ -64,7 +64,6 @@ class AttendanceConsequenceService
$schoolYear = (string) ($payload['school_year'] ?? '');
$dateYmd = (string) ($payload['date'] ?? '');
$dateFmt = $dateYmd !== '' ? date('m-d-Y', strtotime($dateYmd)) : '';
$sentAt = $payload['now'] ?? utc_now();
if ($parentEmail === '') {
Log::warning("Attendance {$type}: missing parent email", [
@@ -75,25 +74,16 @@ class AttendanceConsequenceService
$subject = (string) ($payload['subject'] ?? $this->buildSubject($defaultSubject, $studentName, $dateFmt));
$emailData = [
'title' => $subject,
'student_name' => $studentName,
'parent_name' => $parentName,
'teacher_name' => $teacherName,
'class_section_name' => $className,
'semester' => $semester,
'school_year' => $schoolYear,
'date_fmt' => $dateFmt,
'sent_at' => $sentAt,
];
$html = trim((string) ($payload['html'] ?? ''));
if ($html === '') {
try {
$html = view($viewName, $emailData, ['saveData' => true]);
} catch (\Throwable $e) {
$html = '<p>' . e($subject) . '</p>';
}
$period = trim($semester . ' ' . $schoolYear);
$html = '<p>' . e($subject) . '</p>'
. '<p><strong>Student:</strong> ' . e($studentName) . '</p>'
. '<p><strong>Parent:</strong> ' . e($parentName) . '</p>'
. '<p><strong>Teacher:</strong> ' . e($teacherName) . '</p>'
. '<p><strong>Class:</strong> ' . e($className) . '</p>'
. ($period !== '' ? '<p><strong>Term:</strong> ' . e($period) . '</p>' : '')
. '<p><strong>Date:</strong> ' . e($dateFmt) . '</p>';
}
$ok = false;
@@ -2,11 +2,12 @@
namespace App\Services\Attendance;
use App\Models\AttendanceDay;
use App\Models\Configuration;
use App\Models\StaffAttendance;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use Illuminate\Contracts\Auth\Authenticatable;
use App\Models\ClassSection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -20,6 +21,163 @@ class StaffAttendanceService
protected SemesterRangeService $semesterRangeService,
) {}
/**
* CodeIgniter {@see \App\Controllers\View\AttendanceController::index} — single date / section teacher grid JSON.
*/
public function teacherAttendanceDayIndex(?string $semester, ?string $schoolYear, string $dateYmd, int $sectionCode): array
{
$semester = $semester ?: (string) $this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string) $this->configuration->getConfig('school_year');
$assignedBySection = TeacherClass::assignedBySectionForTerm($semester, $schoolYear);
$sectionCodes = array_keys($assignedBySection);
$labels = [];
foreach ($sectionCodes as $code) {
$code = (int) $code;
if ($code <= 0) {
continue;
}
$name = $this->classSection->query()
->where('class_section_id', $code)
->value('class_section_name');
$name = trim((string) $name);
$labels[$code] = $name !== '' ? $name : ('Section #' . $code);
}
$grid = [];
$locked = false;
if ($sectionCode > 0) {
$assigned = TeacherClass::assignedForSectionTerm($sectionCode, $semester, $schoolYear);
$teacherIds = array_values(array_filter(array_map(static fn ($r) => (int) ($r['teacher_id'] ?? 0), $assigned)));
$statusMap = [];
if ($teacherIds !== []) {
$rows = DB::table('staff_attendance as sa')
->select(['sa.user_id', 'sa.status', 'sa.reason'])
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereDate('sa.date', $dateYmd)
->whereIn('sa.user_id', $teacherIds)
->get();
foreach ($rows as $r) {
$statusMap[(int) $r->user_id] = [
'status' => $r->status ?? null,
'reason' => $r->reason ?? null,
];
}
}
foreach ($assigned as $t) {
$tid = (int) ($t['teacher_id'] ?? 0);
$posRaw = strtolower((string) ($t['position'] ?? 'main'));
$pos = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
$name = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? '')) ?: ('User#' . $tid);
$cell = $statusMap[$tid] ?? ['status' => null, 'reason' => null];
$grid[] = [
'teacher_id' => $tid,
'name' => $name,
'position' => $pos,
'status' => $cell['status'],
'reason' => $cell['reason'],
];
}
try {
$locked = AttendanceDay::isFinalized($sectionCode, $dateYmd, $semester, $schoolYear);
} catch (\Throwable) {
$locked = false;
}
}
return [
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $dateYmd,
'class_section_id' => $sectionCode,
'sections' => $labels,
'grid' => $grid,
'locked' => $locked,
];
}
/**
* Bulk save for CI {@see \App\Controllers\View\AttendanceController::save} (method missing in CI; implemented here).
*/
public function saveTeacherAttendanceBulk(Authenticatable $user, array $payload): array
{
$sectionCode = (int) ($payload['class_section_id'] ?? 0);
$date = substr((string) ($payload['date'] ?? ''), 0, 10);
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$teachers = (array) ($payload['teachers'] ?? []);
if ($sectionCode <= 0 || $date === '' || $semester === '' || $schoolYear === '') {
throw new \RuntimeException('Missing required fields.');
}
$assigned = TeacherClass::assignedForSectionTerm($sectionCode, $semester, $schoolYear);
$assignedByTeacher = [];
foreach ($assigned as $row) {
$assignedByTeacher[(int) ($row['teacher_id'] ?? 0)] = $row;
}
DB::transaction(function () use ($teachers, $assignedByTeacher, $date, $semester, $schoolYear) {
foreach ($teachers as $tidKey => $row) {
$tid = (int) (is_numeric((string) $tidKey) ? $tidKey : ($row['teacher_id'] ?? 0));
if ($tid <= 0) {
continue;
}
$status = strtolower(trim((string) ($row['status'] ?? '')));
$reason = trim((string) ($row['reason'] ?? ''));
$assignment = $assignedByTeacher[$tid] ?? null;
$roleLabel = $assignment
? ((strtolower((string) ($assignment['position'] ?? '')) === 'ta') ? 'Teacher Assistant' : 'Teacher')
: 'Teacher';
if ($status === '') {
DB::table('staff_attendance')
->where('user_id', $tid)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
continue;
}
if (! in_array($status, ['present', 'absent', 'late'], true)) {
continue;
}
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $tid,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => $roleLabel,
'status' => $status,
'reason' => $reason !== '' ? $reason : null,
'updated_at' => now(),
'created_at' => now(),
]
);
}
});
return ['ok' => true, 'message' => 'Teacher attendance saved successfully.'];
}
public function monthData(?string $semester, ?string $schoolYear): array
{
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
@@ -3,6 +3,7 @@
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceEmailTemplate;
use App\Support\MailHtml;
use Illuminate\Support\Str;
class AttendanceEmailComposerService
@@ -64,10 +65,7 @@ class AttendanceEmailComposerService
public function renderWithEmailLayout(string $subject, string $bodyHtml): string
{
return view('emails._wrap_layout', [
'title' => $subject,
'body_html' => $bodyHtml,
])->render();
return MailHtml::document($subject, $bodyHtml);
}
public function pickVariant(string $code, ?string $globalVariantOverride = null): string
@@ -2,6 +2,169 @@
namespace App\Services\AttendanceTracking;
use Illuminate\Support\Facades\Validator;
/**
* Facade used by {@see \App\Http\Controllers\Api\AttendanceTracking\AttendanceTrackingController}.
*/
class AttendanceTrackingService
{
public function __construct(
protected AttendancePendingViolationService $pendingViolationService,
protected AttendanceCaseQueryService $caseQueryService,
protected AttendanceNotificationWorkflowService $workflowService,
protected AttendanceCommunicationSupportService $communicationSupportService,
) {
}
protected function defaultSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? '');
}
protected function defaultSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? '');
}
public function getPendingViolations(?string $schoolYearParam, ?string $semesterParam): array
{
return $this->pendingViolationService->getPendingViolations(
$this->defaultSchoolYear(),
$schoolYearParam,
$semesterParam
);
}
public function getNotifiedViolations(?string $schoolYearParam, ?string $semesterParam): array
{
return $this->caseQueryService->getNotifiedViolations(
$schoolYearParam,
$semesterParam,
$this->defaultSchoolYear(),
$this->defaultSemester()
);
}
public function getStudentCase(
int $studentId,
?string $codeParam,
?string $incidentDate,
bool $includeNotified,
bool $pendingOnly,
?string $schoolYearFilter,
?string $semesterFilter,
): array {
return $this->caseQueryService->getStudentCase(
$studentId,
$codeParam,
$incidentDate,
$includeNotified,
$pendingOnly,
$schoolYearFilter,
$semesterFilter,
$this->defaultSchoolYear(),
$this->defaultSemester()
);
}
public function record(array $data): array
{
return $this->workflowService->record($data);
}
/**
* CodeIgniter POST attendance/send-auto-emails — runs auto_email actions on current pending violations.
*/
public function sendAutoEmails(mixed $variantInput): array
{
$variant = is_string($variantInput) ? $variantInput : null;
$pending = $this->getPendingViolations(null, null);
$violations = $pending['students'] ?? [];
return $this->workflowService->sendAutoEmails($violations, $variant);
}
public function compose(int $studentId, string $code, string $variant, ?string $incidentDate): array
{
return $this->communicationSupportService->compose($studentId, $code, $variant, $incidentDate);
}
public function parentsInfo(int $studentId): array
{
return $this->communicationSupportService->parentsInfo($studentId);
}
public function sendEmailManual(array $data): array
{
$studentId = (int) ($data['student_id'] ?? 0);
$code = (string) ($data['code'] ?? '');
$incidentRaw = $data['incident_date'] ?? null;
$incidentDate = $incidentRaw !== null && $incidentRaw !== ''
? substr((string) $incidentRaw, 0, 10)
: null;
$violationDates = $this->communicationSupportService->getViolationDatesForStudent(
$studentId,
$code,
$incidentDate
);
return $this->workflowService->sendEmailManual($data, $violationDates);
}
public function saveNotificationNote(array $data): array
{
return $this->workflowService->saveNotificationNote($data);
}
/**
* CodeIgniter route referenced {@see View\AttendanceTrackingController::notify_parent} was never implemented in CI;
* this matches the attendance notification modal (student_id, to, subject, message, subject_type, date).
*/
public function notifyParent(array $input): array
{
$validator = Validator::make($input, [
'student_id' => ['required', 'integer', 'min:1'],
'to' => ['required', 'email'],
'subject' => ['required', 'string'],
'message' => ['required', 'string'],
'subject_type' => ['nullable', 'string'],
'date' => ['nullable', 'date'],
]);
if ($validator->fails()) {
return [
'success' => false,
'message' => 'Validation failed.',
'errors' => $validator->errors(),
'status' => 422,
];
}
$data = $validator->validated();
$subjectType = strtolower((string) ($data['subject_type'] ?? 'absent'));
$code = str_contains($subjectType, 'late') ? 'LATE_1' : 'ABS_1';
$ymd = null;
if (! empty($data['date'])) {
try {
$ymd = \Carbon\Carbon::parse((string) $data['date'])->format('Y-m-d');
} catch (\Throwable) {
$ymd = null;
}
}
return $this->sendEmailManual([
'student_id' => (int) $data['student_id'],
'to' => (string) $data['to'],
'subject' => (string) $data['subject'],
'body_html' => (string) $data['message'],
'code' => $code,
'variant' => 'default',
'incident_date' => $ymd,
]);
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Services\Auth;
use App\Models\IpAttempt;
use App\Models\LoginActivity;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Mirrors CodeIgniter `AuthController::apiLogin` security behavior:
* IP lockout (ip_attempts), failed_attempts / suspension after 3 failures, login_activity logging.
*/
class ApiLoginSecurityService
{
public function isIpBlocked(string $ip): bool
{
$row = IpAttempt::query()->where('ip_address', $ip)->first();
if (!$row || !$row->blocked_until) {
return false;
}
return $row->blocked_until->isFuture();
}
public function logIpAttempt(string $ip): void
{
$now = utc_now();
$attempt = IpAttempt::query()->where('ip_address', $ip)->first();
if ($attempt) {
$attempts = ((int) $attempt->attempts) + 1;
$blockedUntil = null;
if ($attempts >= 10) {
$blockedUntil = date('Y-m-d H:i:s', strtotime('+24 hours'));
}
$attempt->update([
'attempts' => $attempts,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
]);
return;
}
IpAttempt::query()->create([
'ip_address' => $ip,
'attempts' => 1,
'last_attempt_at' => $now,
]);
}
public function handleFailedLogin(User $user, string $email, string $ip): void
{
$user->refresh();
$failedAttempts = ((int) ($user->failed_attempts ?? 0)) + 1;
$data = [
'failed_attempts' => $failedAttempts,
'last_failed_at' => utc_now(),
];
if ($failedAttempts >= 3) {
$data['is_suspended'] = true;
// CI sends reset email via View\UserController::sendResetEmail — wire Laravel mail here when parity is needed.
Log::notice('api_login: account suspended after failed attempts', ['email' => $email, 'user_id' => $user->id]);
}
$user->fill($data);
$user->save();
$this->logIpAttempt($ip);
}
public function resetFailedAttempts(User $user): void
{
$user->failed_attempts = 0;
$user->last_failed_at = null;
$user->save();
}
public function logSuccessfulLogin(User $user, Request $request): void
{
LoginActivity::query()->create([
'user_id' => $user->id,
'email' => $user->email,
'login_time' => utc_now(),
'ip_address' => $request->ip(),
'user_agent' => (string) ($request->userAgent() ?? ''),
]);
}
/**
* CI returns roles as an object map {"RoleName": true}.
*
* @param list<string> $roleNames
*/
public function rolesToObjectMap(array $roleNames): object
{
$rolesMap = [];
foreach ($roleNames as $r) {
$rolesMap[$r] = true;
}
return (object) $rolesMap;
}
/**
* Top-level JSON body aligned with the CodeIgniter 4 `AuthController::apiLogin` success payload.
*
* @return array{status: true, token: string, user: array{id: int, name: string, roles: object}}
*/
public function buildLoginResponse(User $user, string $jwtToken): array
{
$roleNames = $user->roleNamesLikeCodeIgniter();
$fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
return [
'status' => true,
'token' => $jwtToken,
'user' => [
'id' => (int) $user->id,
'name' => $fullName,
'roles' => $this->rolesToObjectMap($roleNames),
],
];
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Auth;
use App\Models\Configuration;
use App\Services\ApplicationUrlService;
use App\Models\LoginActivity;
use App\Models\Preferences;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
/**
* CodeIgniter `AuthController` web (session) flow: session keys, dashboards, redirects, preferences.
*/
class AuthSessionService
{
private const FALLBACK_DASHBOARD = '/landing_page/guest_dashboard';
public function __construct(
private ApplicationUrlService $urls,
) {
}
/**
* Same-host relative redirect sanitization as CI `sanitizeRedirectTarget`.
*/
public function sanitizeRedirectTarget(string $redirectTo): ?string
{
$redirectTo = trim($redirectTo);
if ($redirectTo === '') {
return null;
}
if (preg_match('#^https?://#i', $redirectTo)) {
$appHost = (string) parse_url($this->urls->docsHomeUrl(), PHP_URL_HOST);
$targetHost = (string) parse_url($redirectTo, PHP_URL_HOST);
$targetPath = (string) parse_url($redirectTo, PHP_URL_PATH);
$targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY);
if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) {
return null;
}
$redirectTo = $targetPath !== '' ? $targetPath : '/';
if ($targetQuery !== '') {
$redirectTo .= '?'.$targetQuery;
}
}
if (! str_starts_with($redirectTo, '/')) {
$redirectTo = '/'.ltrim($redirectTo, '/');
}
if (str_starts_with($redirectTo, '//')) {
return null;
}
return $redirectTo;
}
/**
* Highest-priority dashboard route matching any of the role names/slugs.
*
* @param list<string> $roleNames
*/
public function dashboardRouteForRoles(array $roleNames): string
{
if ($roleNames === []) {
return self::FALLBACK_DASHBOARD;
}
$rows = Role::findByNamesOrSlugs($roleNames);
if ($rows !== []) {
$route = $rows[0]->dashboard_route ?? null;
if (is_string($route) && $route !== '') {
return $route;
}
}
return self::FALLBACK_DASHBOARD;
}
/**
* Apply CI-style preference keys into session (style/menu colors).
*/
public function applyStylePreferencesToSession(int $userId): void
{
if ($userId <= 0) {
return;
}
$prefs = Preferences::query()->where('user_id', $userId)->first();
if (! $prefs) {
return;
}
$styleColor = (string) ($prefs->style_color ?? '');
if ($styleColor !== '') {
session()->put('style_color', $styleColor);
}
$menuColor = (string) ($prefs->menu_color ?? '');
if ($menuColor === 'custom') {
session()->put('menu_color', 'custom');
session()->put('menu_custom_bg', (string) ($prefs->menu_custom_bg ?? '#0f172a'));
session()->put('menu_custom_text', (string) ($prefs->menu_custom_text ?? '#ffffff'));
session()->put('menu_custom_mode', (string) ($prefs->menu_custom_mode ?? 'dark'));
} elseif ($menuColor !== '') {
session()->put('menu_color', $menuColor);
}
}
/**
* Populate Laravel web auth + CI-compatible session keys.
*
* @param list<string> $roleNames
*/
public function writeCiAuthenticatedSession(User $user, array $roleNames): void
{
Auth::guard('web')->login($user);
$schoolYear = Configuration::getConfig('school_year');
$semester = Configuration::getConfig('semester');
session([
'user_id' => $user->id,
'user_email' => $user->email,
'user_name' => trim(($user->firstname ?? '').' '.($user->lastname ?? '')),
'user_type' => $user->user_type ?? null,
'is_logged_in' => true,
'login_time' => time(),
'last_activity' => time(),
'roles' => $roleNames,
'semester' => $semester,
'school_year' => $schoolYear,
]);
$this->applyStylePreferencesToSession((int) $user->id);
}
/**
* Close open login_activity row(s) without logout_time for this user (CI parity).
*/
public function logLogout(?int $userId, ?string $email): void
{
if (! $userId || ! $email) {
return;
}
LoginActivity::query()
->where('user_id', $userId)
->whereNull('logout_time')
->update([
'logout_time' => utc_now(),
'email' => $email,
]);
}
/**
* Determine next navigation after credentials validated (single vs multi-role).
*
* @return array{kind: string, redirect_url?: string|null, roles?: array<int, string>, reason?: string}
*/
public function resolvePostLoginDestination(User $user, ?string $sanitizedRedirect): array
{
$roleNames = $user->roleNamesLikeCodeIgniter();
if ($roleNames === []) {
Log::notice('session_login: user has no roles', ['user_id' => $user->id]);
return [
'kind' => 'no_roles',
'reason' => 'Role not assigned. Please contact support.',
];
}
$this->writeCiAuthenticatedSession($user, $roleNames);
if (count($roleNames) === 1) {
session()->put('role', $roleNames[0]);
if ($sanitizedRedirect !== null) {
return ['kind' => 'redirect', 'redirect_url' => $sanitizedRedirect];
}
return [
'kind' => 'redirect',
'redirect_url' => $this->dashboardRouteForRoles([$roleNames[0]]),
];
}
if ($sanitizedRedirect !== null) {
session()->put('post_login_redirect', $sanitizedRedirect);
} else {
session()->forget('post_login_redirect');
}
return [
'kind' => 'select_role',
'roles' => array_values($roleNames),
'redirect_url' => $this->urls->webSelectRoleUrl(false),
];
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Services\Auth;
use App\Models\Configuration;
use App\Models\Role;
use App\Models\User;
use App\Models\UserRole;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
/**
* Matches CodeIgniter 4 `AuthController::apiRegister` (public API register, no captcha).
*/
class CodeIgniterApiRegistrationService
{
public function registerResponse(Request $request): JsonResponse
{
$requestData = $request->all();
if ($request->isJson() && ($requestData === [] || $requestData === null)) {
$decoded = json_decode((string) $request->getContent(), true);
$requestData = is_array($decoded) ? $decoded : [];
}
$validator = Validator::make($requestData, [
'firstname' => ['required', 'string', 'min:2', 'max:30'],
'lastname' => ['required', 'string', 'min:2', 'max:30'],
'email' => ['required', 'email'],
'password' => ['required', 'string', 'min:8'],
'cellphone' => ['required', 'string', 'min:10', 'max:20'],
'gender' => ['nullable', 'string'],
'address_street' => ['nullable', 'string'],
'city' => ['nullable', 'string'],
'state' => ['nullable', 'string'],
'zip' => ['nullable', 'string'],
'role' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'status' => false,
'message' => 'Validation failed',
'errors' => $validator->errors()->toArray(),
], 422);
}
/** @var array<string, mixed> $data */
$data = $validator->validated();
if (User::query()->where('email', $data['email'])->exists()) {
return response()->json([
'status' => false,
'message' => 'Email already registered',
], 422);
}
$schoolYear = Configuration::getConfig('school_year');
$semester = Configuration::getConfig('semester');
$userData = [
'firstname' => $data['firstname'],
'lastname' => $data['lastname'],
'email' => $data['email'],
'password' => pbkdf2_hash((string) $data['password']),
'cellphone' => $data['cellphone'],
'gender' => $data['gender'] ?? null,
'address_street' => $data['address_street'] ?? '',
'city' => $data['city'] ?? '',
'state' => $data['state'] ?? '',
'zip' => $data['zip'] ?? '',
'school_year' => $schoolYear,
'semester' => $semester ?? '',
'status' => 'active',
'is_verified' => 0,
'accept_school_policy' => 0,
];
try {
$userId = null;
$rolesForResponse = [];
DB::transaction(function () use ($userData, $data, &$userId, &$rolesForResponse): void {
$user = User::query()->create($userData);
$userId = (int) $user->id;
if (($data['role'] ?? '') === 'parent') {
$role = Role::query()->where('name', 'parent')->first();
if ($role) {
UserRole::query()->create([
'user_id' => $userId,
'role_id' => (int) $role->id,
]);
$rolesForResponse = ['parent'];
}
}
});
$user = User::query()->findOrFail($userId);
$token = JWTAuth::fromUser($user);
$fullName = trim((string) $user->firstname.' '.(string) $user->lastname);
return response()->json([
'status' => true,
'message' => 'Registration successful. Please verify your email.',
'token' => $token,
'user' => [
'id' => $userId,
'name' => $fullName,
'email' => $userData['email'],
'roles' => $rolesForResponse,
],
], 201);
} catch (\Throwable $e) {
log_message('error', 'Registration error: '.$e->getMessage());
return response()->json([
'status' => false,
'message' => 'Registration failed. Please try again.',
], 500);
}
}
}
+8 -19
View File
@@ -7,6 +7,7 @@ use App\Models\ParentModel;
use App\Models\Role;
use App\Models\User;
use App\Models\UserRole;
use App\Services\ApplicationUrlService;
use App\Services\EmailService;
use App\Services\SchoolIdService;
use Illuminate\Support\Facades\DB;
@@ -17,7 +18,8 @@ class RegistrationService
private EmailService $emailService,
private SchoolIdService $schoolIdService,
private RegistrationFormatterService $formatter,
private RegistrationCaptchaService $captchaService
private RegistrationCaptchaService $captchaService,
private ApplicationUrlService $urls,
) {
}
@@ -125,26 +127,13 @@ class RegistrationService
}
});
$activationLink = url('/user/confirm/' . $token);
$activationLink = $this->urls->spaRegistrationConfirmUrl($token);
$recipientName = trim((string) ($formatted['firstname'] ?? '') . ' ' . (string) ($formatted['lastname'] ?? ''));
$emailData = [
'recipientName' => $recipientName,
'activationLink' => $activationLink,
'orgName' => 'Al Rahma Sunday School',
'contactInfo' => 'alrahma.isgl@gmail.com',
'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png',
'subject' => 'Email Confirmation',
];
if (view()->exists($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff')) {
$html = view($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff', $emailData);
} else {
$html = '<p>Hello ' . e($recipientName !== '' ? $recipientName : 'there') . ',</p>'
. '<p>Please confirm your email by clicking the link below:</p>'
. '<p><a href="' . e($activationLink) . '">Activate your account</a></p>'
. '<p>Thank you.</p>';
}
$html = '<p>Hello ' . e($recipientName !== '' ? $recipientName : 'there') . ',</p>'
. '<p>Please confirm your email by clicking the link below:</p>'
. '<p><a href="' . e($activationLink) . '">Activate your account</a></p>'
. '<p>Thank you.</p>';
$sent = $this->emailService->send($email, 'Email Confirmation', $html, 'general');
$this->captchaService->clear();
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services\BadgeScan;
use App\Models\Configuration;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* CodeIgniter {@see \App\Controllers\View\RFIDController} parity (badge scan + log listing).
*/
class BadgeScanService
{
private const LOG_LIMIT = 500;
/**
* @return array{recognized: bool, message: string, user_id?: int, display_name?: string}
*/
public function processScan(string $badgeScan): array
{
$tag = trim($badgeScan);
if ($tag === '') {
return [
'recognized' => false,
'message' => 'Badge scan value is required.',
];
}
if (! Schema::hasTable('scan_log')) {
return [
'recognized' => false,
'message' => 'Scan logging is not available.',
];
}
$user = User::query()->where('rfid_tag', $tag)->first();
if (! $user) {
return [
'recognized' => false,
'message' => 'Badge scan not recognized.',
];
}
$displayName = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
if ($displayName === '') {
$displayName = $user->email ?? ('User #'.$user->id);
}
DB::table('scan_log')->insert([
'user_id' => $user->id,
'card_id' => $tag,
'scan_time' => now(),
'school_year' => Configuration::getConfigValueByKey('school_year'),
'semester' => Configuration::getConfigValueByKey('semester'),
]);
return [
'recognized' => true,
'message' => 'Badge recognized: '.$displayName,
'user_id' => (int) $user->id,
'display_name' => $displayName,
];
}
/**
* Scan log listing (legacy CI RFIDController::log() query shape).
*
* @return list<array<string, mixed>>
*/
public function scanLogRows(): array
{
if (! Schema::hasTable('scan_log')) {
return [];
}
return DB::table('scan_log')
->select([
'scan_log.id',
'scan_log.user_id',
'scan_log.card_id',
'scan_log.scan_time',
'scan_log.school_year',
'scan_log.semester',
'users.firstname as user_firstname',
'users.lastname as user_lastname',
'students.firstname as student_firstname',
'students.lastname as student_lastname',
])
->leftJoin('users', 'users.id', '=', 'scan_log.user_id')
->leftJoin('students', 'students.rfid_tag', '=', 'scan_log.card_id')
->orderByDesc('scan_log.scan_time')
->limit(self::LOG_LIMIT)
->get()
->map(function ($row) {
$r = (array) $row;
foreach (['scan_time'] as $k) {
if (isset($r[$k]) && $r[$k] !== null && ! is_string($r[$k])) {
$r[$k] = (string) $r[$k];
}
}
return $r;
})
->all();
}
}
+396 -126
View File
@@ -1,157 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Services\Badges;
use FPDF;
use chillerlan\QRCode\Output\QRGdImagePNG;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Illuminate\Http\Response;
use Throwable;
class BadgePdfService
{
private const BADGE_WIDTH_IN = 2.36;
private const BADGE_HEIGHT_IN = 3.54;
private const CELL_WIDTH_IN = 2.55;
private const CELL_HEIGHT_IN = 3.85;
private const PAGE_MARGIN_IN = 0.18;
/** @var list<string> */
private array $tempQrFiles = [];
public function __construct(
protected BadgeUserLookupService $lookupService,
protected BadgeStudentLookupService $studentLookupService,
protected BadgeUserLookupService $userLookupService,
protected BadgePrintLogService $printLogService,
protected BadgeTextFormatter $formatter
protected BadgeTextFormatter $formatter,
) {
}
/**
* Letter landscape PDF, 8 badges per page — students and/or staff (FPDF + QR PNG).
*
* @param int[] $studentIds Primary keys on `students.id`
* @param int[] $userIds Staff/admin/teacher keys on `users.id`
*/
public function generate(
array $studentIds,
array $userIds,
?string $schoolYear,
array $rolesMap = [],
array $classesMap = [],
?int $actorId = null
): Response {
$userIds = $this->formatter->normalizeIds($userIds);
$this->tempQrFiles = [];
$pdf = new FPDF('P', 'mm', 'A4');
$pdf->SetAutoPageBreak(false);
$badgeW = 95;
$badgeH = 67;
$marginL = 14;
$marginT = 6;
$gutterX = 0;
$gutterY = 0;
$cols = 2;
$rows = 4;
$perPage = $cols * $rows;
$pagesAdded = 0;
$i = 0;
$seen = [];
foreach ($userIds as $uid) {
$info = $this->lookupService->getUserInfoById($uid, $schoolYear);
if (!$info || !is_array($info)) {
continue;
try {
if (!class_exists('FPDF', false)) {
require_once base_path('app/ThirdParty/fpdf/fpdf.php');
}
$userId = (int) ($info['user_id'] ?? $uid);
$name = $this->formatter->norm($info['name'] ?? '');
$studentIds = $this->formatter->normalizeIds($studentIds);
$userIds = $this->formatter->normalizeIds($userIds);
$postedRole = $rolesMap[$userId] ?? null;
$roleResolved = $postedRole !== null
? $postedRole
: $this->formatter->resolveRole($info);
$schoolName = (string) config('badges.school_name', 'Al Rahma Sunday School');
$motto = (string) config('badges.motto', '');
$studentRoleLabel = (string) config('badges.role_label', 'Student');
$footerBlock = $this->footerBlock($schoolName);
$logoPath = $this->logoFilePath();
$roleResolved = $this->formatter->formatRole((string) $roleResolved);
$info['role_resolved'] = $roleResolved;
$rowsWithQr = [];
if (!empty($classesMap[$userId])) {
$info['class_section_name'] = (string) $classesMap[$userId];
$students = $this->studentLookupService->fetchForBadges($studentIds, $schoolYear);
foreach ($students as $row) {
try {
$png = $this->renderQrPng((string) $row['school_id']);
$tmpPath = $this->writeTempQrPng($png);
if ($tmpPath === null) {
continue;
}
$rowsWithQr[] = array_merge($row, [
'_badge_kind' => 'student',
'role_subline' => $studentRoleLabel,
'_qr_tmp' => $tmpPath,
]);
} catch (Throwable) {
continue;
}
}
$class = $this->formatter->norm($info['class_section_name'] ?? '');
foreach ($userIds as $uid) {
$staffRow = $this->buildStaffBadgeRow((int) $uid, $schoolYear, $rolesMap, $classesMap);
if ($staffRow === null) {
continue;
}
$badgeKey = strtolower(trim($userId . '|' . $name . '|' . $roleResolved . '|' . $class));
if (isset($seen[$badgeKey])) {
continue;
}
$seen[$badgeKey] = true;
try {
$qrPayload = (string) ($staffRow['user_id_for_qr'] ?? $staffRow['user_id'] ?? '');
if ($qrPayload === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $qrPayload)) {
$qrPayload = (string) (int) ($staffRow['user_id'] ?? 0);
}
if (($i % $perPage) === 0) {
$pdf->AddPage();
$pagesAdded++;
$png = $this->renderQrPng($qrPayload);
$tmpPath = $this->writeTempQrPng($png);
if ($tmpPath === null) {
continue;
}
$rowsWithQr[] = array_merge($staffRow, [
'_badge_kind' => 'staff',
'_qr_tmp' => $tmpPath,
]);
} catch (Throwable) {
continue;
}
}
$indexOnPage = $i % $perPage;
$row = intdiv($indexOnPage, $cols);
$col = $indexOnPage % $cols;
$pdf = new \FPDF('L', 'in', 'Letter');
$pdf->SetAutoPageBreak(false);
$x = $marginL + $col * ($badgeW + $gutterX);
$y = $marginT + $row * ($badgeH + $gutterY);
$pages = array_chunk($rowsWithQr, 8);
$this->drawBadgeInCell($pdf, $info, $x, $y, $badgeW, $badgeH, $schoolYear);
$i++;
if ($pages === []) {
$pdf->AddPage('L', 'Letter');
$pdf->SetFont('Helvetica', '', 12);
$pdf->SetTextColor(0, 0, 0);
$pdf->SetXY(self::PAGE_MARGIN_IN, self::PAGE_MARGIN_IN);
$pdf->MultiCell(
$pdf->GetPageWidth() - 2 * self::PAGE_MARGIN_IN,
0.3,
$this->formatter->toPdf(
'No valid badges: check user/student ids, roles, or (for students) school id for QR.'
),
0,
'L'
);
} else {
foreach ($pages as $pageRows) {
$pdf->AddPage('L', 'Letter');
$slots = array_pad($pageRows, 8, null);
for ($gridRow = 0; $gridRow < 2; $gridRow++) {
for ($gridCol = 0; $gridCol < 4; $gridCol++) {
$idx = ($gridRow * 4) + $gridCol;
$row = $slots[$idx];
if (!is_array($row) || empty($row['_qr_tmp'])) {
continue;
}
$cellX = self::PAGE_MARGIN_IN + $gridCol * self::CELL_WIDTH_IN;
$cellY = self::PAGE_MARGIN_IN + $gridRow * self::CELL_HEIGHT_IN;
$badgeX = $cellX + (self::CELL_WIDTH_IN - self::BADGE_WIDTH_IN) / 2;
$badgeY = $cellY + (self::CELL_HEIGHT_IN - self::BADGE_HEIGHT_IN) / 2;
$this->drawBadge(
$pdf,
$row,
$badgeX,
$badgeY,
self::BADGE_WIDTH_IN,
self::BADGE_HEIGHT_IN,
$schoolName,
$motto,
$studentRoleLabel,
$footerBlock,
$logoPath
);
}
}
}
}
$binary = $pdf->Output('S');
$logIds = array_values(array_unique(array_merge($studentIds, $userIds)));
$this->printLogService->logSafely(
userIds: $logIds,
actorId: $actorId,
schoolYear: $schoolYear,
rolesMap: $rolesMap,
classesMap: $classesMap,
copies: 1
);
return response((string) $binary, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="Badges.pdf"',
]);
} finally {
foreach ($this->tempQrFiles as $path) {
if (is_string($path) && $path !== '' && is_file($path)) {
@unlink($path);
}
}
$this->tempQrFiles = [];
}
if ($pagesAdded === 0) {
$pdf->AddPage();
$pdf->SetFont('Arial', '', 10);
$pdf->SetXY(10, 20);
$pdf->MultiCell(0, 6, 'No valid staff selected or data not found.', 0, 'L');
}
$pdfString = $pdf->Output('S');
$this->printLogService->logSafely(
userIds: $userIds,
actorId: $actorId,
schoolYear: $schoolYear,
rolesMap: $rolesMap,
classesMap: $classesMap,
copies: 1
);
return response($pdfString, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="Staff_Badges.pdf"',
]);
}
protected function drawBadgeInCell(FPDF $pdf, array $data, float $x, float $y, float $w, float $h, ?string $schoolYear): void
{
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $w, $h, 'F');
$pdf->SetDrawColor(200, 200, 200);
$pdf->Rect($x, $y, $w, $h);
$logoSize = 6;
if (!empty($data['school_logo']) && file_exists($data['school_logo'])) {
$pdf->Image($data['school_logo'], $x + $w - 6 - $logoSize, $y + 4, $logoSize + 3, $logoSize - 1);
}
if (!empty($data['isgl_logo']) && file_exists($data['isgl_logo'])) {
$pdf->Image($data['isgl_logo'], $x + 4, $y + 4, $logoSize + 3, $logoSize - 1);
/**
* @param array<string, mixed> $rolesMap
* @param array<string, mixed> $classesMap
* @return ?array<string, mixed>
*/
protected function buildStaffBadgeRow(
int $userId,
?string $schoolYear,
array $rolesMap,
array $classesMap
): ?array {
$info = $this->userLookupService->getUserInfoById($userId, $schoolYear);
if (!$info || !is_array($info)) {
return null;
}
$padX = 4;
$cursorY = $y + 4 + $logoSize + 2;
$resolvedId = (int) ($info['user_id'] ?? $userId);
$schoolName = strtoupper($this->formatter->norm($data['school_name'] ?? 'AL RAHMA SUNDAY SCHOOL'));
$pdf->SetFont('Arial', '', 16);
$pdf->SetXY($x + $padX, $cursorY);
$pdf->MultiCell($w - 2 * $padX, 5, $this->formatter->toPdf($schoolName), 0, 'C');
$postedRole = $rolesMap[$resolvedId] ?? null;
$roleResolved = $postedRole !== null
? (string) $postedRole
: $this->formatter->resolveRole($info);
$pdf->Ln(9);
$pdf->SetFont('Arial', 'B', 14);
$pdf->SetX($x + $padX);
$name = strtoupper($this->formatter->norm($data['name'] ?? 'STAFF'));
$pdf->Cell($w - 2 * $padX, 6, $this->formatter->toPdf($name), 0, 1, 'C');
$roleResolved = $this->formatter->formatRole($roleResolved);
$info['role_resolved'] = $roleResolved;
$roleResolved = $this->formatter->norm((string) ($data['role_resolved'] ?? ''));
if ($roleResolved === '') {
$roleResolved = $this->formatter->resolveRole($data);
}
if ($roleResolved !== '') {
$roleResolved = $this->formatter->formatRole($roleResolved);
if (!empty($classesMap[$resolvedId])) {
$info['class_section_name'] = (string) $classesMap[$resolvedId];
}
$classRaw = $this->formatter->norm($data['class_section_name'] ?? '');
$class = $this->formatter->formatClass($classRaw);
$class = $this->formatter->norm($info['class_section_name'] ?? '');
$class = $this->formatter->formatClass($class);
$detectSrc = strtolower($this->formatter->norm(
($data['roles_raw'] ?? '') !== '' ? $data['roles_raw'] : (($data['role_name_raw'] ?? '') !== '' ? $data['role_name_raw'] : $roleResolved)
($info['roles'] ?? '') !== '' ? (string) $info['roles'] : $roleResolved
));
$isTeacherish = str_contains($detectSrc, 'teacher') || preg_match('/\bta\b/', $detectSrc);
@@ -171,26 +247,220 @@ class BadgePdfService
$display = 'STAFF';
}
$pdf->Ln(6);
$pdf->SetFont('Arial', '', 14);
$displayOut = $this->formatter->toPdf($display);
$maxTextWidth = $w - 2 * $padX;
$best = $this->formatter->fitText($pdf, $displayOut, 11, 7, $maxTextWidth);
$pdf->SetFont('Arial', '', $best + 4);
$gradeCol = $class !== '' ? $class : '—';
$year = $this->formatter->norm((string) ($info['school_year'] ?? ($schoolYear ?? '')));
if ($pdf->GetStringWidth($displayOut) <= $maxTextWidth) {
$pdf->SetX($x + $padX);
$pdf->Cell($maxTextWidth, 5, $displayOut, 0, 1, 'C');
} else {
$pdf->SetX($x + $padX);
$pdf->MultiCell($maxTextWidth, 5, $displayOut, 0, 'C');
return [
'user_id' => $resolvedId,
'user_id_for_qr' => (string) $resolvedId,
'fullname' => $info['name'] ?? 'STAFF',
'grade' => $gradeCol,
'academic_year' => $year !== '' ? $year : '',
'school_id' => (string) $resolvedId,
'role_subline' => $display,
];
}
protected function writeTempQrPng(string $png): ?string
{
$tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'badge_qr_' . bin2hex(random_bytes(8)) . '.png';
if (@file_put_contents($tmpPath, $png) === false) {
return null;
}
$footerYear = $this->formatter->norm((string) ($schoolYear ?? ($data['school_year'] ?? '')));
if ($footerYear !== '') {
$pdf->SetFont('Arial', 'I', 14);
$pdf->SetXY($x + $padX, $y + $h - 9);
$pdf->Cell($w - 2 * $padX, 4, $this->formatter->toPdf($footerYear), 0, 0, 'C');
$this->tempQrFiles[] = $tmpPath;
return $tmpPath;
}
/**
* @param array<string, mixed> $student
*/
protected function drawBadge(
\FPDF $pdf,
array $student,
float $x,
float $y,
float $w,
float $h,
string $schoolName,
string $motto,
string $defaultStudentRoleLabel,
string $footerBlock,
?string $logoPath
): void {
$kind = (string) ($student['_badge_kind'] ?? 'student');
$idLabel = $kind === 'staff' ? 'USER ID' : 'SCHOOL ID';
$blue = [11, 58, 130];
$blueLight = [27, 76, 160];
$qrPath = (string) ($student['_qr_tmp'] ?? '');
$fullName = $this->formatter->toPdf((string) ($student['fullname'] ?? ''));
$grade = $this->formatter->toPdf((string) ($student['grade'] ?? ''));
$year = $this->formatter->toPdf((string) ($student['academic_year'] ?? ''));
$schoolId = $this->formatter->toPdf((string) ($student['school_id'] ?? ''));
$schoolNamePdf = $this->formatter->toPdf($schoolName);
$mottoPdf = $this->formatter->toPdf($motto);
$rolePdf = $this->formatter->toPdf((string) ($student['role_subline'] ?? $defaultStudentRoleLabel));
$pdf->SetDrawColor($blue[0], $blue[1], $blue[2]);
$pdf->SetLineWidth(0.003);
$pdf->Rect($x, $y, $w, $h);
$headerH = 0.55;
$footerH = 0.38;
$pdf->SetFillColor($blue[0], $blue[1], $blue[2]);
$pdf->Rect($x, $y, $w, $headerH, 'F');
$logoSize = 0.12;
if ($logoPath !== null && is_readable($logoPath)) {
try {
$pdf->Image($logoPath, $x + $w / 2 - $logoSize / 2, $y + 0.035, $logoSize, $logoSize);
} catch (Throwable) {
}
}
$pdf->SetTextColor(255, 255, 255);
$pdf->SetFont('Helvetica', 'B', 8.5);
$pdf->SetXY($x + 0.06, $y + 0.035 + $logoSize + 0.01);
$pdf->Cell($w - 0.12, 0.1, $schoolNamePdf, 0, 0, 'C');
if ($mottoPdf !== '') {
$pdf->SetFont('Helvetica', 'B', 5.2);
$pdf->SetXY($x + 0.06, $y + $headerH - 0.12);
$pdf->Cell($w - 0.12, 0.08, $mottoPdf, 0, 0, 'C');
}
$bodyTop = $y + $headerH + 0.06;
$pdf->SetTextColor(11, 47, 115);
$pdf->SetFont('Helvetica', 'B', 9.5);
$pdf->SetXY($x + 0.07, $bodyTop);
$pdf->Cell($w - 0.14, 0.11, strtoupper($fullName), 0, 1, 'C');
$pdf->SetTextColor(27, 86, 177);
$pdf->SetFont('Helvetica', 'B', 6);
$pdf->SetX($x + 0.07);
$pdf->Cell($w - 0.14, 0.07, strtoupper($rolePdf), 0, 1, 'C');
$ruleY = $bodyTop + 0.22;
$pdf->SetDrawColor(215, 222, 234);
$pdf->Line($x + 0.08, $ruleY, $x + $w - 0.08, $ruleY);
$infoLeft = $x + 0.08;
$infoW = $w * 0.52;
$labelY = $ruleY + 0.05;
$gradeLabel = $kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE';
$pdf->SetTextColor(51, 51, 51);
$pdf->SetFont('Helvetica', 'B', 5);
$pdf->SetXY($infoLeft, $labelY);
$pdf->Cell($infoW, 0.05, $gradeLabel, 0, 1, 'L');
$pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]);
$pdf->SetFont('Helvetica', 'B', 7.5);
$pdf->SetX($infoLeft);
$pdf->Cell($infoW, 0.08, $grade, 0, 1, 'L');
$pdf->SetTextColor(51, 51, 51);
$pdf->SetFont('Helvetica', 'B', 5);
$pdf->SetX($infoLeft);
$pdf->Cell($infoW, 0.05, 'ACADEMIC YEAR', 0, 1, 'L');
$pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]);
$pdf->SetFont('Helvetica', 'B', 7.5);
$pdf->SetX($infoLeft);
$pdf->Cell($infoW, 0.08, $year, 0, 1, 'L');
$pdf->SetTextColor(51, 51, 51);
$pdf->SetFont('Helvetica', 'B', 5);
$pdf->SetX($infoLeft);
$pdf->Cell($infoW, 0.05, $idLabel, 0, 1, 'L');
$pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]);
$pdf->SetFont('Helvetica', 'B', 7.5);
$pdf->SetX($infoLeft);
$pdf->MultiCell($infoW, 0.09, $schoolId, 0, 'L');
$qrSize = 0.38;
$qrX = $x + $w - 0.08 - $qrSize;
$qrY = $ruleY + 0.04;
if ($qrPath !== '' && is_file($qrPath)) {
try {
$pdf->SetDrawColor($blueLight[0], $blueLight[1], $blueLight[2]);
$pdf->Rect($qrX - 0.03, $qrY - 0.03, $qrSize + 0.06, $qrSize + 0.06);
$pdf->Image($qrPath, $qrX, $qrY, $qrSize, $qrSize, 'PNG');
} catch (Throwable) {
}
}
$pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]);
$pdf->SetFont('Helvetica', 'B', 4.5);
$pdf->SetXY($qrX - 0.05, $qrY + $qrSize + 0.02);
$pdf->Cell($qrSize + 0.1, 0.05, 'SCAN TO VERIFY', 0, 0, 'C');
$fy = $y + $h - $footerH;
$pdf->SetFillColor($blue[0], $blue[1], $blue[2]);
$pdf->Rect($x, $fy, $w, $footerH, 'F');
$smallLogo = 0.1;
if ($logoPath !== null && is_readable($logoPath)) {
try {
$pdf->Image($logoPath, $x + $w / 2 - $smallLogo / 2, $fy + 0.03, $smallLogo, $smallLogo);
} catch (Throwable) {
}
}
$pdf->SetTextColor(255, 255, 255);
$pdf->SetFont('Helvetica', 'B', 4.5);
$lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock];
$lineY = $fy + 0.03 + $smallLogo + 0.015;
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$pdf->SetXY($x + 0.04, $lineY);
$pdf->Cell($w - 0.08, 0.055, $this->formatter->toPdf($line), 0, 1, 'C');
$lineY += 0.055;
}
}
}
protected function renderQrPng(string $payload): string
{
$qrOptions = new QROptions([
'version' => 5,
'outputInterface' => QRGdImagePNG::class,
'outputBase64' => false,
'eccLevel' => QRCode::ECC_M,
'scale' => 4,
]);
$qr = new QRCode($qrOptions);
return $qr->render($payload);
}
protected function logoFilePath(): ?string
{
return $this->formatter->firstExisting([
public_path('assets/images/school_logo.png'),
public_path('assets/images/logo.png'),
]);
}
protected function footerBlock(string $schoolName): string
{
$address = trim((string) config('badges.footer_address', ''));
if ($address !== '') {
return $schoolName . "\n" . $address;
}
return $schoolName;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Services\Badges;
use App\Models\Student;
use App\Models\StudentClass;
class BadgeStudentLookupService
{
public function __construct(
protected BadgeTextFormatter $formatter
) {
}
/**
* Load students for badge printing: valid school_id (QR payload), ordered by name.
*
* @param int[] $studentIds
* @return array<int, array{student_id:int, fullname:string, grade:string, academic_year:string, school_id:string}>
*/
public function fetchForBadges(array $studentIds, ?string $schoolYear): array
{
$studentIds = $this->formatter->normalizeIds($studentIds);
if ($studentIds === []) {
return [];
}
$rows = Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->orderBy('lastname')
->orderBy('firstname')
->get();
$out = [];
foreach ($rows as $student) {
$schoolId = trim((string) ($student->school_id ?? ''));
if ($schoolId === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $schoolId)) {
continue;
}
$fullname = Student::getFullName($student->toArray());
$grade = $this->resolveGrade((int) $student->id, (string) ($student->registration_grade ?? ''));
$academicYear = $schoolYear ?? (string) ($student->school_year ?? '');
$academicYear = $this->formatter->norm($academicYear);
$out[] = [
'student_id' => (int) $student->id,
'fullname' => $fullname !== '' ? $fullname : 'Student',
'grade' => $grade,
'academic_year' => $academicYear,
'school_id' => $schoolId,
];
}
return $out;
}
protected function resolveGrade(int $studentId, string $registrationGrade): string
{
$registrationGrade = $this->formatter->norm($registrationGrade);
if ($registrationGrade !== '') {
return $registrationGrade;
}
try {
$g = StudentClass::getStudentGrade($studentId);
$g = $this->formatter->norm((string) $g);
return $g !== '' ? $g : '—';
} catch (\Throwable) {
return '—';
}
}
}
@@ -2,7 +2,7 @@
namespace App\Services\BroadcastEmail;
use Illuminate\Support\Facades\View;
use App\Support\MailHtml;
class BroadcastEmailComposerService
{
@@ -30,16 +30,6 @@ class BroadcastEmailComposerService
return $content;
}
if (!View::exists('emails.broadcast_wrapper')) {
return $content;
}
return view('emails.broadcast_wrapper', [
'subject' => $subject,
'content' => $content,
'preheader' => $preheader,
'cta_text' => $ctaText,
'cta_url' => $ctaUrl,
])->render();
return MailHtml::broadcastCompose($subject, $content, $preheader, $ctaText, $ctaUrl);
}
}
@@ -2,7 +2,9 @@
namespace App\Services\ClassProgress;
use App\Models\Configuration;
use App\Models\SubjectCurriculum;
use App\Services\SemesterRangeService;
class ClassProgressMetaService
{
@@ -16,22 +18,126 @@ class ClassProgressMetaService
return (array) config('progress.status_options', []);
}
/**
* Fallback: next N Sundays from today (CI `buildUpcomingSundayOptions`).
*
* @return list<string>
*/
public function sundayOptions(int $count = 12): array
{
$start = now();
if ((int) $start->format('w') !== 0) {
$start = $start->next('Sunday');
return $this->buildUpcomingSundayOptions($count);
}
/**
* CI-aligned Sundays within semester/school-year range when configuration allows.
*
* @return list<string>
*/
public function sundayOptionsAlignedWithCi(int $count = 12): array
{
$range = $this->resolveProgressDateRange();
if ($range === null) {
return $this->buildUpcomingSundayOptions($count);
}
[$rangeStart, $rangeEnd] = $range;
try {
$start = new \DateTime($rangeStart);
$end = new \DateTime($rangeEnd);
} catch (\Exception $e) {
return $this->buildUpcomingSundayOptions($count);
}
if ($end < $start) {
[$start, $end] = [$end, $start];
}
$weekday = (int) $start->format('w');
if ($weekday !== 0) {
$start->modify('next sunday');
}
$options = [];
while ($start <= $end) {
$options[] = $start->format('Y-m-d');
$start->modify('+7 days');
}
return $options !== [] ? $options : $this->buildUpcomingSundayOptions($count);
}
/** CI `pickDefaultWeekStart`. */
public function pickDefaultWeekStart(array $options): string
{
if ($options === []) {
return '';
}
$today = date('Y-m-d');
$default = '';
foreach ($options as $option) {
if ($option <= $today) {
$default = $option;
continue;
}
break;
}
return $default !== '' ? $default : $options[0];
}
/**
* @return list<string>
*/
private function buildUpcomingSundayOptions(int $count): array
{
$start = new \DateTime('today');
$weekday = (int) $start->format('w');
if ($weekday !== 0) {
$start->modify('next sunday');
}
$options = [];
for ($i = 0; $i < $count; $i++) {
$options[] = $start->format('Y-m-d');
$start = $start->addDays(7);
$start->modify('+7 days');
}
return $options;
}
/**
* CI `resolveProgressDateRange`.
*
* @return array{0:string,1:string}|null
*/
private function resolveProgressDateRange(): ?array
{
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
if ($schoolYear === '') {
return null;
}
/** @var SemesterRangeService $svc */
$svc = app(SemesterRangeService::class);
$semester = $svc->normalizeSemester((string) (Configuration::getConfig('semester') ?? ''));
if ($semester === '') {
$semester = $svc->getSemesterForDate();
}
if ($semester !== '') {
$range = $svc->getSemesterRange($schoolYear, $semester);
if ($range !== null) {
return $range;
}
}
return $svc->getSchoolYearRange($schoolYear);
}
public function curriculumOptions(?int $classId): array
{
if (!$classId) {
@@ -2,10 +2,11 @@
namespace App\Services\ClassProgress;
use App\Models\ClassProgressAttachment;
use App\Models\ClassProgressReport;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -13,7 +14,8 @@ class ClassProgressMutationService
{
public function __construct(
private ClassProgressRuleService $rules,
private ClassProgressAttachmentService $attachments
private ClassProgressAttachmentService $attachments,
private ClassProgressQueryService $queries,
) {}
public function createReports(User $user, array $payload, array $filesBySubject): Collection
@@ -30,7 +32,31 @@ class ClassProgressMutationService
throw new \InvalidArgumentException('Week end must be the same as or after the week start.');
}
$reports = DB::transaction(function () use ($user, $payload, $subjectSections, $classSectionId, $weekStart, $weekEnd, $filesBySubject) {
if (!$this->rules->hasIslamicUnitSelection($payload)) {
throw new \InvalidArgumentException('Please select at least one Islamic Studies unit.');
}
$confirmOverwrite = filter_var($payload['confirm_overwrite'] ?? false, FILTER_VALIDATE_BOOLEAN);
$existingIds = ClassProgressReport::query()
->where('class_section_id', $classSectionId)
->whereDate('week_start', $weekStart)
->where('teacher_id', $user->id)
->pluck('id')
->map(fn ($id) => (int) $id)
->filter()
->values()
->all();
if ($existingIds !== [] && !$confirmOverwrite) {
throw new \InvalidArgumentException('CONFIRM_OVERWRITE');
}
$reports = DB::transaction(function () use ($user, $payload, $subjectSections, $classSectionId, $weekStart, $weekEnd, $filesBySubject, $confirmOverwrite, $existingIds) {
if ($existingIds !== [] && $confirmOverwrite) {
ClassProgressAttachment::query()->whereIn('report_id', $existingIds)->delete();
ClassProgressReport::query()->whereIn('id', $existingIds)->delete();
}
$created = collect();
foreach ($subjectSections as $slug => $section) {
@@ -83,6 +109,160 @@ class ClassProgressMutationService
return $reports;
}
/**
* CI `ClassProgressController::update` — sync all subjects for the weekly batch keyed by {@see $anchor}.
*
* @return Collection<int, ClassProgressReport>
*/
public function syncWeeklyFromTeacherForm(User $user, ClassProgressReport $anchor, array $payload, array $filesBySubject): Collection
{
$subjectSections = (array) config('progress.subject_sections', []);
$classSectionId = (int) ($payload['class_section_id'] ?? $anchor->class_section_id);
$this->assertTeacherAssignment($user, $classSectionId);
$weekStart = (string) ($payload['week_start'] ?? '');
$weekEnd = $this->rules->ensureWeekEnd($weekStart, $payload['week_end'] ?? null);
if (! $this->rules->isWeekEndValid($weekStart, $weekEnd)) {
throw new \InvalidArgumentException('Week end must be the same as or after the week start.');
}
if (! $this->rules->hasIslamicUnitSelection($payload)) {
throw new \InvalidArgumentException('Please select at least one Islamic Studies unit.');
}
$originalWeekStart = $anchor->week_start instanceof \Carbon\CarbonInterface
? $anchor->week_start->format('Y-m-d')
: (string) $anchor->week_start;
$confirmOverwrite = filter_var($payload['confirm_overwrite'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($weekStart !== '' && $weekStart !== $originalWeekStart) {
$conflicts = ClassProgressReport::query()
->where('class_section_id', $classSectionId)
->whereDate('week_start', $weekStart)
->where('teacher_id', $user->id)
->pluck('id')
->map(fn ($id) => (int) $id)
->filter()
->values()
->all();
if ($conflicts !== [] && ! $confirmOverwrite) {
throw new \InvalidArgumentException('CONFIRM_OVERWRITE');
}
if ($conflicts !== [] && $confirmOverwrite) {
ClassProgressAttachment::query()->whereIn('report_id', $conflicts)->delete();
ClassProgressReport::query()->whereIn('id', $conflicts)->delete();
}
}
$allowedIds = $this->queries->resolveAssignedTeacherIds($classSectionId);
if ($allowedIds === []) {
$allowedIds = [(int) $user->id];
}
return DB::transaction(function () use (
$user,
$anchor,
$payload,
$subjectSections,
$classSectionId,
$weekStart,
$weekEnd,
$filesBySubject,
$allowedIds,
$originalWeekStart,
): Collection {
$weeklyBatch = ClassProgressReport::query()
->whereIn('teacher_id', $allowedIds)
->where('class_section_id', $classSectionId)
->whereDate('week_start', $originalWeekStart)
->orderBy('subject')
->get();
$reportMap = [];
foreach ($weeklyBatch as $rep) {
$subject = (string) ($rep->subject ?? '');
if ($subject !== '') {
$reportMap[$subject] = $rep;
}
}
$updated = collect();
$flagsPresent = array_key_exists('flags', $payload);
foreach ($subjectSections as $slug => $section) {
$covered = trim((string) ($payload["covered_{$slug}"] ?? ''));
if ($covered === '') {
continue;
}
$homework = trim((string) ($payload["homework_{$slug}"] ?? ''));
$unitValues = (array) ($payload["unit_{$slug}"] ?? []);
$chapterValues = (array) ($payload["chapter_{$slug}"] ?? []);
$unitTitle = $this->rules->buildUnitChapterSummary($unitValues, $chapterValues);
$subjectName = $section['db_subject'] ?? $section['label'] ?? $slug;
$existing = $reportMap[$subjectName] ?? null;
if ($unitTitle === null && $existing) {
$unitTitle = $existing->unit_title;
}
$data = [
'class_section_id' => $classSectionId,
'week_start' => $weekStart,
'week_end' => $weekEnd,
'subject' => $subjectName,
'unit_title' => $unitTitle,
'covered' => $covered,
'homework' => $homework !== '' ? $homework : null,
];
if ($flagsPresent) {
$data['flags_json'] = $this->rules->normalizeFlags($payload['flags'] ?? null);
}
if ($existing) {
$existing->fill($data);
$existing->save();
$saved = $existing->fresh();
} else {
$data['teacher_id'] = $user->id;
$data['status'] = $this->rules->defaultStatus();
$saved = ClassProgressReport::query()->create($data);
}
if (! $saved) {
continue;
}
$updated->push($saved);
$attachments = $filesBySubject[$slug] ?? [];
$stored = $this->attachments->storeAttachments((int) $saved->id, $attachments);
if ($stored !== [] && ! $saved->attachment_path) {
$saved->update(['attachment_path' => $stored[0]['file_path'] ?? null]);
}
}
if ($updated->isEmpty()) {
throw new \InvalidArgumentException('Please provide progress for at least one subject.');
}
Log::info('Class progress weekly sync completed.', [
'teacher_id' => $user->id,
'anchor_id' => $anchor->id,
'count' => $updated->count(),
]);
return $updated;
});
}
public function updateReport(ClassProgressReport $report, array $payload, array $attachments): ClassProgressReport
{
return DB::transaction(function () use ($report, $payload, $attachments) {
@@ -4,16 +4,13 @@ namespace App\Services\ClassProgress;
use App\Models\ClassProgressAttachment;
use App\Models\ClassProgressReport;
use App\Models\Configuration;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ClassProgressQueryService
{
public function __construct(
private ClassProgressRuleService $rules
) {}
public function listReports(User $user, array $filters): LengthAwarePaginator
{
$query = ClassProgressReport::query()
@@ -21,8 +18,13 @@ class ClassProgressQueryService
->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc');
if (!$this->isAdmin($user)) {
$query->where('teacher_id', $user->id);
} elseif (!empty($filters['teacher_id'])) {
if (! empty($filters['class_section_id'])) {
$ids = $this->resolveAssignedTeacherIds((int) $filters['class_section_id']);
$query->whereIn('teacher_id', $ids !== [] ? $ids : [(int) $user->id]);
} else {
$query->where('teacher_id', $user->id);
}
} elseif (! empty($filters['teacher_id'])) {
$query->where('teacher_id', (int) $filters['teacher_id']);
}
@@ -53,12 +55,17 @@ class ClassProgressQueryService
public function getReport(User $user, int $reportId): ClassProgressReport
{
$query = ClassProgressReport::query()->with('classSection');
if (!$this->isAdmin($user)) {
$query->where('teacher_id', $user->id);
$report = ClassProgressReport::query()->with('classSection')->findOrFail($reportId);
if ($this->isAdmin($user)) {
return $report;
}
return $query->findOrFail($reportId);
if ($this->viewerMayAccessReport($user, $report)) {
return $report;
}
abort(404);
}
public function weeklyReports(User $user, ClassProgressReport $report): array
@@ -69,8 +76,9 @@ class ClassProgressQueryService
->whereDate('week_start', $report->week_start)
->orderBy('subject', 'asc');
if (!$this->isAdmin($user)) {
$query->where('teacher_id', $user->id);
if (! $this->isAdmin($user)) {
$ids = $this->resolveAssignedTeacherIds((int) $report->class_section_id);
$query->whereIn('teacher_id', $ids !== [] ? $ids : [(int) $user->id]);
}
$rows = $query->get();
@@ -137,4 +145,40 @@ class ClassProgressQueryService
return false;
}
/**
* CI `ClassProgressController::resolveAssignedTeacherIds`.
*
* @return list<int>
*/
public function resolveAssignedTeacherIds(int $classSectionId): array
{
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
if ($schoolYear === '' || $classSectionId <= 0) {
return [];
}
$semester = (string) (Configuration::getConfig('semester') ?? '');
$rows = TeacherClass::assignedForSectionTerm($classSectionId, $semester, $schoolYear);
$ids = [];
foreach ($rows as $row) {
$id = (int) ($row['teacher_id'] ?? 0);
if ($id > 0) {
$ids[$id] = true;
}
}
return array_map('intval', array_keys($ids));
}
private function viewerMayAccessReport(User $user, ClassProgressReport $report): bool
{
if ((int) $report->teacher_id === (int) $user->id) {
return true;
}
$ids = $this->resolveAssignedTeacherIds((int) $report->class_section_id);
return $ids !== [] && in_array((int) $user->id, $ids, true);
}
}
@@ -4,6 +4,9 @@ namespace App\Services\ClassProgress;
class ClassProgressRuleService
{
/** CI `ClassProgressController::CUSTOM_UNIT_ROW_LABEL` — must match teacher form JS. */
public const CUSTOM_UNIT_ROW_LABEL = 'Custom';
public function defaultStatus(): string
{
return 'on_track';
@@ -15,6 +18,12 @@ class ClassProgressRuleService
return $values === [] ? null : $values;
}
/**
* CI `buildUnitChapterSummary` — Custom / typed chapter rows preserved.
*
* @param array<int|string, mixed> $unitValues
* @param array<int|string, mixed> $chapterValues
*/
public function buildUnitChapterSummary(array $unitValues, array $chapterValues): ?string
{
$parts = [];
@@ -25,9 +34,12 @@ class ClassProgressRuleService
if ($unit === '' && $chapter === '') {
continue;
}
if (strcasecmp($unit, self::CUSTOM_UNIT_ROW_LABEL) === 0 && $chapter !== '') {
$unit = self::CUSTOM_UNIT_ROW_LABEL;
}
$segment = $unit;
if ($chapter !== '') {
$segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter;
$segment = $segment !== '' ? $unit.' / '.$chapter : $chapter;
}
if ($segment === '') {
continue;
@@ -40,9 +52,25 @@ class ClassProgressRuleService
}
$summary = implode(' ; ', $parts);
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
}
/** CI `hasIslamicUnitSelection`. */
public function hasIslamicUnitSelection(array $payload): bool
{
$unitValues = array_map('trim', (array) ($payload['unit_islamic'] ?? []));
$chapterValues = array_map('trim', (array) ($payload['chapter_islamic'] ?? []));
$count = max(count($unitValues), count($chapterValues));
for ($i = 0; $i < $count; $i++) {
if (($unitValues[$i] ?? '') !== '' || ($chapterValues[$i] ?? '') !== '') {
return true;
}
}
return false;
}
public function ensureWeekEnd(string $weekStart, ?string $weekEnd): string
{
if ($weekEnd) {
@@ -62,4 +90,47 @@ class ClassProgressRuleService
{
return strtotime($weekEnd) >= strtotime($weekStart);
}
/**
* CI `parseUnitChapterSummary` — inverse of {@see buildUnitChapterSummary()} for edit forms.
*
* @return array{units: list<string>, chapters: list<string>}
*/
public function parseUnitChapterSummary(string $summary): array
{
$summary = trim($summary);
if ($summary === '') {
return ['units' => [], 'chapters' => []];
}
$units = [];
$chapters = [];
$segments = preg_split('/\s*;\s*/', $summary, -1, PREG_SPLIT_NO_EMPTY);
foreach ($segments as $segment) {
$segment = trim((string) $segment);
if ($segment === '') {
continue;
}
if (preg_match('/^Custom\s*\/\s*(.+)$/iu', $segment, $m)) {
$units[] = self::CUSTOM_UNIT_ROW_LABEL;
$chapters[] = trim($m[1]);
continue;
}
$parts = preg_split('/\s*\/\s*/', $segment, 2);
if (count($parts) === 2) {
$u = trim($parts[0]);
if (strcasecmp($u, self::CUSTOM_UNIT_ROW_LABEL) === 0) {
$u = self::CUSTOM_UNIT_ROW_LABEL;
}
$units[] = $u;
$chapters[] = trim($parts[1]);
} else {
$units[] = $segment;
$chapters[] = '';
}
}
return ['units' => $units, 'chapters' => $chapters];
}
}
@@ -9,11 +9,17 @@ use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class DiscountInvoiceService
{
public function __construct(
private ApplicationUrlService $urls,
) {
}
public function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
{
$invoice = Invoice::query()->find($invoiceId);
@@ -347,7 +353,7 @@ class DiscountInvoiceService
'invoice_total' => (float) $invoice->total_amount,
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'portalLink' => url('parent/invoices/' . $invoiceId),
'portalLink' => $this->urls->spaParentInvoiceDetailUrl($invoiceId),
];
return [$data, $studentRows];
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Services\Docs;
use App\Models\User;
use App\Services\ApplicationUrlService;
class ApiDocsService
{
public function __construct(
private ApplicationUrlService $urls,
) {
}
/**
* Bootstrap payload for the documentation SPA (replaces CI swagger_ui / swagger_ui_public views).
*
* @return array<string, mixed>
*/
public function bootstrap(?User $user, bool $public): array
{
return [
'variant' => $public ? 'public' : 'full',
'title' => $public
? 'Al Rahma API — Public Docs'
: 'Al Rahma Sunday School — API Docs',
'openapi_url' => $this->urls->docsSwaggerMergedJsonUrl(false),
'try_it_out_enabled' => ! $public,
'persist_authorization' => ! $public,
'show_authorize_button' => ! $public,
'welcome_name' => $public ? null : $this->welcomeName($user),
];
}
private function welcomeName(?User $user): ?string
{
if (! $user) {
return null;
}
$full = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
return $full !== '' ? $full : ($user->email ?: 'Admin');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Docs;
use App\Services\ApplicationUrlService;
/**
* CodeIgniter parity: {@see \App\Controllers\DocsController::index} and
* {@see \App\Controllers\View\DocsController::swagger} (multi-spec Swagger UI payload).
*/
class DocsCatalogService
{
public function __construct(
private ApplicationUrlService $urls,
) {
}
/**
* Hub metadata for the docs landing route (replaces Blade `docs/index`).
*
* @return array<string, mixed>
*/
public function indexPayload(): array
{
return [
'title' => (string) config('docs.index.title', 'API documentation'),
'description' => (string) config('docs.index.description', ''),
'links' => [
'swagger_catalog' => $this->urls->docsSwaggerCatalogUrl(),
'merged_openapi_json' => $this->urls->docsSwaggerMergedJsonUrl(),
'docs_spa' => config('docs.client_url'),
'public_bootstrap' => $this->urls->apiDocsPublicBootstrapUrl(),
'admin_bootstrap' => $this->urls->apiDocsAdminBootstrapUrl(),
],
];
}
/**
* Multi-spec dropdown payload (CI `View\DocsController::swagger` / `swagger_ui` view).
*
* @return array{specs: list<array{name: string, url: string}>, merged_openapi_url: string}
*/
public function swaggerSpecsPayload(): array
{
$specs = [];
foreach ((array) config('docs.swagger_specs', []) as $row) {
if (! is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
$path = trim((string) ($row['path'] ?? ''));
if ($name === '' || $path === '') {
continue;
}
$specs[] = [
'name' => $name,
'url' => $this->urls->absolutePublicPathUrl($path),
];
}
return [
'specs' => $specs,
'merged_openapi_url' => $this->urls->docsSwaggerMergedJsonUrl(),
];
}
}
@@ -2,10 +2,16 @@
namespace App\Services\Expenses;
use App\Services\ApplicationUrlService;
use Illuminate\Http\UploadedFile;
class ExpenseReceiptService
{
public function __construct(
private ApplicationUrlService $urls
) {
}
public function storeReceipt(UploadedFile $file): string
{
$stored = $file->store('receipts');
@@ -14,9 +20,6 @@ class ExpenseReceiptService
public function receiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
return url('receipts/' . $filename);
return $this->urls->forReceiptStorage($filename);
}
}
@@ -5,6 +5,7 @@ namespace App\Services\ExtraCharges;
use App\Models\AdditionalCharge;
use App\Models\Invoice;
use App\Models\User;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -12,7 +13,8 @@ class ExtraChargesChargeService
{
public function __construct(
private ExtraChargesContextService $context,
private ExtraChargesInvoiceService $invoiceService
private ExtraChargesInvoiceService $invoiceService,
private ApplicationUrlService $urls,
) {}
public function createCharge(array $payload): array
@@ -197,8 +199,10 @@ class ExtraChargesChargeService
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'invoice_total' => $invoiceTotal,
'portal_link' => url('/login'),
'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'),
'portal_link' => $this->urls->webLoginUrl(),
'invoice_link' => $invoiceId
? $this->urls->spaParentInvoiceViewUrl($invoiceId)
: $this->urls->spaParentInvoicesIndexUrl(),
];
$students = [];
@@ -4,13 +4,15 @@ namespace App\Services\Frontend;
use App\Models\Invoice;
use App\Models\StudentClass;
use App\Services\Parents\PrimaryParentUserResolver;
use Illuminate\Support\Facades\DB;
class LandingPageParentDashboardService
{
public function __construct(private LandingPageContextService $context)
{
}
public function __construct(
private LandingPageContextService $context,
private PrimaryParentUserResolver $primaryParentResolver,
) {}
public function summary(int $parentUserId, string $userType): array
{
@@ -18,7 +20,7 @@ class LandingPageParentDashboardService
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$parentId = $this->resolveParentId($parentUserId, $userType);
$parentId = $this->primaryParentResolver->resolveFromCredentials($parentUserId, $userType);
if (!$parentId) {
return [
'ok' => false,
@@ -89,31 +91,6 @@ class LandingPageParentDashboardService
];
}
private function resolveParentId(int $userId, string $userType): ?int
{
if ($userType === 'primary') {
return $userId;
}
if ($userType === 'secondary') {
$row = DB::table('parents')
->select('parent_id')
->where('secondparent_user_id', $userId)
->first();
return $row ? (int) $row->parent_id : null;
}
if ($userType === 'tertiary') {
$row = DB::table('authorized_users')
->select('user_id as parent_id')
->where('authorized_user_id', $userId)
->first();
return $row ? (int) $row->parent_id : null;
}
return null;
}
private function notificationsForParent(int $parentId): array
{
return DB::table('notifications')
@@ -2,6 +2,7 @@
namespace App\Services\Grading;
use App\Support\MailHtml;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -84,11 +85,7 @@ class BelowSixtyEmailService
$html = trim((string) ($payload['html'] ?? ''));
if ($html === '') {
try {
$html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]);
} catch (\Throwable $e) {
$html = '<p>' . e($subject) . '</p>';
}
$html = MailHtml::document($subject, MailHtml::belowSixtyInnerHtml($emailData));
}
$sent = 0;
@@ -0,0 +1,166 @@
<?php
namespace App\Services\Parents;
use App\Models\AuthorizedUser;
use App\Models\User;
use App\Services\ApplicationUrlService;
use App\Services\Email\EmailDispatchService;
use Illuminate\Support\Facades\DB;
/**
* CodeIgniter {@see \App\Controllers\View\AuthorizedUsersController} parity.
*/
class AuthorizedUsersManagementService
{
private const TOKEN_TTL_HOURS = 24;
public function __construct(
private EmailDispatchService $mail,
private ApplicationUrlService $urls,
) {}
public function hashToken(string $plain): string
{
return hash('sha256', $plain);
}
/**
* CI create() first step — lookup by token after invite email (pending row, created within TTL).
*/
public function findPendingByIncomingToken(string $plainToken): ?AuthorizedUser
{
if ($plainToken === '') {
return null;
}
$hash = $this->hashToken($plainToken);
return AuthorizedUser::query()
->where(function ($q) use ($hash, $plainToken) {
$q->where('token', $hash)->orWhere('token', $plainToken);
})
->where('created_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
->first();
}
/**
* CI setPassword / savePassword — active row with rotating token.
*/
public function findActiveSetupRow(int $authorizedUserId, string $plainToken): ?AuthorizedUser
{
if ($plainToken === '') {
return null;
}
$hash = $this->hashToken($plainToken);
return AuthorizedUser::query()
->where(function ($q) use ($hash, $plainToken) {
$q->where('token', $hash)->orWhere('token', $plainToken);
})
->where('authorized_user_id', $authorizedUserId)
->where('status', 'Active')
->where('updated_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
->first();
}
/**
* @return array{redirect_url: string}
*/
public function confirmEmailClick(string $plainToken): array
{
$row = $this->findPendingByIncomingToken($plainToken);
if (! $row) {
throw new \InvalidArgumentException('Invalid or expired confirmation link.');
}
$nextPlain = bin2hex(random_bytes(48));
$nextHash = $this->hashToken($nextPlain);
$row->status = 'Active';
$row->token = $nextHash;
$row->save();
$url = $this->urls->inviteSetPasswordFormUrl((int) $row->authorized_user_id, $nextPlain);
return ['redirect_url' => $url];
}
/**
* CI `create` — when email is not a registered user, respond success without leaking (privacy).
*
* @return array{created: bool, record: AuthorizedUser|null}
*/
public function inviteByEmail(int $primaryUserId, string $email): array
{
$email = strtolower(trim($email));
$user = User::query()
->whereRaw('LOWER(email) = ?', [$email])
->first();
if (! $user) {
return ['created' => false, 'record' => null];
}
$plain = bin2hex(random_bytes(48));
$tokenHash = $this->hashToken($plain);
$phone = trim((string) ($user->cellphone ?? ''));
$record = AuthorizedUser::query()->create([
'user_id' => $primaryUserId,
'authorized_user_id' => (int) $user->id,
'firstname' => (string) ($user->firstname ?? ''),
'lastname' => (string) ($user->lastname ?? ''),
'phone_number' => $phone !== '' ? $phone : '-',
'gender' => trim((string) ($user->gender ?? '')) !== '' ? (string) $user->gender : '-',
'email' => $email,
'relation_to_user' => null,
'token' => $tokenHash,
'status' => 'Pending',
]);
$this->sendConfirmationEmail($email, $plain);
return ['created' => true, 'record' => $record];
}
public function sendConfirmationEmail(string $email, string $plainToken): void
{
$confirmLink = $this->urls->inviteConfirmUrl($plainToken);
$body = '<p>You have been added as an authorized user for another account. Click the link below to confirm your access:</p>'
.'<p><a href="'.e($confirmLink).'">Confirm Access</a></p>'
.'<p>If you did not request this, please ignore this email.</p>';
$this->mail->send($email, 'Authorized User Confirmation', $body, 'notifications');
}
/**
* @throws \InvalidArgumentException
*/
public function setAuthorizedUserPassword(int $authorizedUserId, int $postedUserId, string $plainToken, string $password): void
{
if ($postedUserId !== $authorizedUserId || $authorizedUserId <= 0) {
throw new \InvalidArgumentException('Invalid request.');
}
$row = $this->findActiveSetupRow($authorizedUserId, $plainToken);
if (! $row) {
throw new \InvalidArgumentException('Invalid or expired confirmation link.');
}
$user = User::query()->find($authorizedUserId);
if (! $user) {
throw new \InvalidArgumentException('User not found.');
}
DB::transaction(function () use ($user, $row, $password) {
$user->password = $password;
$user->save();
$row->token = null;
$row->save();
});
}
}
@@ -4,10 +4,12 @@ namespace App\Services\Parents;
use App\Models\AttendanceData;
use App\Models\AttendanceRecord;
use App\Models\EarlyDismissalSignature;
use App\Models\ParentAttendanceReport;
use App\Models\Student;
use App\Models\ClassSection;
use App\Services\SemesterRangeService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
class ParentAttendanceReportService
@@ -254,12 +256,12 @@ class ParentAttendanceReportService
];
}
public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester): array
public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester, int $parentId): array
{
return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester);
return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester, $parentId);
}
public function checkExisting(array $payload): array
public function checkExisting(int $parentId, array $payload): array
{
$datesPost = $payload['dates'] ?? null;
$dateSingle = $payload['date'] ?? null;
@@ -286,6 +288,15 @@ class ParentAttendanceReportService
return [];
}
$uniqueIds = array_values(array_unique($studentIds));
$ownedCount = Student::query()
->where('parent_id', $parentId)
->whereIn('id', $uniqueIds)
->count();
if ($ownedCount !== count($uniqueIds)) {
throw new \RuntimeException('Invalid student selection.');
}
$rows = DB::table('parent_attendance_reports as par')
->select('par.student_id', 'par.type', 'par.report_date', 's.firstname', 's.lastname')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
@@ -371,6 +382,275 @@ class ParentAttendanceReportService
$row->update($update);
}
/**
* Staff index: early dismissal rows grouped by date (+ signature metadata per date).
*
* Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::earlyDismissals}.
*
* @return array{groups: array<string, array<int, array<string, mixed>>>, school_year: string, semester: string, signature_by_date: array<string, array<string, mixed>>}
*/
public function staffEarlyDismissalsIndex(?string $schoolYearParam, ?string $semesterParam): array
{
$context = $this->configService->context();
$schoolYear = filled($schoolYearParam) ? trim((string) $schoolYearParam) : (string) ($context['school_year'] ?? '');
$semester = filled($semesterParam) ? trim((string) $semesterParam) : '';
$b = DB::table('parent_attendance_reports as par')
->select('par.*', 's.firstname', 's.lastname', 'cs.class_section_name')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'par.class_section_id')
->where('par.type', '=', 'early_dismissal');
if ($schoolYear !== '') {
$b->where('par.school_year', $schoolYear);
}
if ($semester !== '') {
$b->where('par.semester', $semester);
}
$rows = $b->orderByDesc('par.report_date')
->orderBy('par.dismiss_time')
->get()
->map(fn ($r) => (array) $r)
->all();
$groups = [];
foreach ($rows as $r) {
$d = substr((string) ($r['report_date'] ?? ''), 0, 10);
if ($d === '') {
$d = 'Unknown';
}
$groups[$d][] = $r;
}
uksort($groups, static function ($a, $b) {
if ($a === 'Unknown' && $b === 'Unknown') {
return 0;
}
if ($a === 'Unknown') {
return 1;
}
if ($b === 'Unknown') {
return -1;
}
return strcmp((string) $b, (string) $a);
});
$signatureByDate = [];
$dates = array_values(array_filter(array_keys($groups), static function ($d) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $d) === 1;
}));
if ($dates !== []) {
$sigQ = EarlyDismissalSignature::query()->whereIn('report_date', $dates);
if ($schoolYear !== '') {
$sigQ->where('school_year', $schoolYear);
}
if ($semester !== '') {
$sigQ->where('semester', $semester);
}
foreach ($sigQ->orderBy('report_date')->get() as $sig) {
$d = $sig->report_date instanceof \DateTimeInterface
? $sig->report_date->format('Y-m-d')
: substr((string) $sig->report_date, 0, 10);
if ($d !== '') {
$signatureByDate[$d] = $sig->toArray();
}
}
}
return [
'groups' => $groups,
'school_year' => $schoolYear,
'semester' => $semester,
'signature_by_date' => $signatureByDate,
];
}
/**
* Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::addEarlyDismissalForm}.
*
* @return array{students: array<int, array<string, mixed>>, default_date: string, school_year: string, semester: string}
*/
public function staffAddEarlyDismissalFormData(): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
try {
$students = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 's.parent_id', 'cs.class_section_name', 'sc.class_section_id')
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
->map(fn ($r) => (array) $r)
->all();
} catch (\Throwable) {
$students = [];
}
[, $defaultDate] = $this->calendarService->computeSundays(0, 26);
return [
'students' => $students,
'default_date' => $defaultDate,
'school_year' => $schoolYear,
'semester' => $semester,
];
}
/**
* Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::saveEarlyDismissal}.
*/
public function saveStaffEarlyDismissal(int $studentId, string $reportDate, string $dismissTime, string $reason): void
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$dt = \DateTime::createFromFormat('Y-m-d', $reportDate);
if (! $dt || (int) $dt->format('w') !== 0) {
throw new \RuntimeException('Please select a Sunday date.');
}
$stu = Student::query()->select('id', 'parent_id', 'firstname', 'lastname')->find($studentId);
if (! $stu || empty($stu->parent_id)) {
throw new \RuntimeException('Selected student not found or has no primary parent assigned.');
}
$parentId = (int) $stu->parent_id;
$sectionRow = DB::table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->first();
$classSectionId = $sectionRow ? (int) $sectionRow->class_section_id : null;
$qb = ParentAttendanceReport::query()
->where('student_id', $studentId)
->whereDate('report_date', $reportDate)
->where('school_year', $schoolYear);
$existingAl = (clone $qb)->where(function ($q) {
$q->where('type', 'absent')->orWhere('type', 'late');
})->first();
if ($existingAl) {
throw new \RuntimeException('Absent/Late already submitted for this student on the selected date.');
}
$existingEd = (clone $qb)->where('type', 'early_dismissal')->first();
if ($existingEd) {
$existingEd->update([
'dismiss_time' => $dismissTime !== '' ? $dismissTime : $existingEd->dismiss_time,
'reason' => $reason !== '' ? $reason : $existingEd->reason,
'status' => 'new',
]);
return;
}
ParentAttendanceReport::query()->create([
'parent_id' => $parentId,
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'report_date' => $reportDate,
'type' => 'early_dismissal',
'dismiss_time' => $dismissTime,
'reason' => $reason !== '' ? $reason : null,
'semester' => $semester,
'school_year' => $schoolYear,
'status' => 'new',
]);
}
/**
* Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::uploadEarlyDismissalSignature}.
* Files live under {@see storage_path('uploads/early_dismissal_signatures')} (same as {@see \App\Http\Controllers\Api\Reports\FilesController::earlyDismissalSignature}).
*/
public function storeEarlyDismissalSignature(
UploadedFile $file,
string $reportDate,
?string $schoolYearPost,
?string $semesterPost,
int $uploadedByUserId,
): void {
$dt = \DateTime::createFromFormat('Y-m-d', $reportDate);
if (! $dt || (int) $dt->format('w') !== 0) {
throw new \RuntimeException('Please select a Sunday date.');
}
$context = $this->configService->context();
$schoolYear = $schoolYearPost !== null && $schoolYearPost !== ''
? (string) $schoolYearPost
: (string) ($context['school_year'] ?? '');
$semesterRaw = (string) ($semesterPost ?? '');
$hasSemesterFilter = $semesterRaw !== '';
$semester = $hasSemesterFilter ? $semesterRaw : null;
$existsQ = ParentAttendanceReport::query()
->where('type', 'early_dismissal')
->whereDate('report_date', $reportDate);
if ($schoolYear !== '') {
$existsQ->where('school_year', $schoolYear);
}
if ($hasSemesterFilter) {
$existsQ->where('semester', $semester);
}
if (! $existsQ->exists()) {
throw new \RuntimeException('No early dismissal records found for that date.');
}
$targetDir = storage_path('uploads/early_dismissal_signatures');
if (! is_dir($targetDir) && ! @mkdir($targetDir, 0755, true) && ! is_dir($targetDir)) {
throw new \RuntimeException('Upload directory is not available.');
}
$originalName = $file->getClientOriginalName();
$mimeType = $file->getClientMimeType();
$fileSize = (int) $file->getSize();
$filename = $file->hashName();
$file->move($targetDir, $filename);
$payload = [
'report_date' => $reportDate,
'school_year' => $schoolYear !== '' ? $schoolYear : null,
'semester' => $semester,
'filename' => $filename,
'original_name' => $originalName,
'mime_type' => $mimeType,
'file_size' => $fileSize,
'uploaded_by' => $uploadedByUserId,
];
$existingQ = EarlyDismissalSignature::query()->whereDate('report_date', $reportDate);
if ($schoolYear !== '') {
$existingQ->where('school_year', $schoolYear);
}
if ($hasSemesterFilter) {
$existingQ->where('semester', $semester);
}
$existing = $existingQ->first();
if ($existing) {
$oldFile = (string) ($existing->filename ?? '');
$existing->update($payload);
if ($oldFile !== '' && $oldFile !== $filename) {
@unlink($targetDir . DIRECTORY_SEPARATOR . $oldFile);
}
} else {
EarlyDismissalSignature::query()->create($payload);
}
}
private function resolveClassSectionLabel(?int $classSectionId): string
{
$cid = (int) ($classSectionId ?? 0);
@@ -0,0 +1,191 @@
<?php
namespace App\Services\Parents;
use App\Models\ClassProgressReport;
use App\Services\ClassProgress\ClassProgressQueryService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* CodeIgniter {@see \App\Controllers\ParentProgressController} read-side logic.
*/
class ParentProgressQueryService
{
public function __construct(
private ClassProgressQueryService $classProgressQuery,
) {}
/**
* CI `getParentStudents` — one row per distinct student (latest enrollment ordering).
*
* @return list<array<string, mixed>>
*/
public function parentStudents(int $parentId): array
{
if ($parentId <= 0) {
return [];
}
$rows = DB::table('enrollments as e')
->select(
'e.student_id',
'e.class_section_id',
'e.updated_at',
'e.created_at',
's.firstname',
's.lastname',
'cs.class_section_name',
)
->join('students as s', 's.id', '=', 'e.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id')
->where('e.parent_id', $parentId)
->where('e.is_withdrawn', 0)
->orderByDesc('e.updated_at')
->orderByDesc('e.created_at')
->get();
$students = [];
foreach ($rows as $row) {
$arr = (array) $row;
$studentId = (int) ($arr['student_id'] ?? 0);
if ($studentId === 0 || isset($students[$studentId])) {
continue;
}
$students[$studentId] = $arr;
}
return array_values($students);
}
/**
* CI `getParentSectionIds`.
*
* @return list<int>
*/
public function parentSectionIds(int $parentId): array
{
if ($parentId <= 0) {
return [];
}
$ids = DB::table('enrollments')
->where('parent_id', $parentId)
->where('is_withdrawn', 0)
->whereNotNull('class_section_id')
->distinct()
->pluck('class_section_id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->unique()
->values()
->all();
return array_values($ids);
}
public function parentCanAccessSection(int $parentUserId, ?int $classSectionId): bool
{
if (! $classSectionId) {
return false;
}
return in_array((int) $classSectionId, $this->parentSectionIds($parentUserId), true);
}
/**
* Progress reports visible to the parent (all teachers) for allowed sections.
*
* @param list<int> $sectionIds
*/
public function reportsForSections(array $sectionIds): Collection
{
$sectionIds = array_values(array_filter(array_map('intval', $sectionIds)));
if ($sectionIds === []) {
return collect();
}
return ClassProgressReport::query()
->select('class_progress_reports.*')
->addSelect([
'cs.class_section_name',
DB::raw('CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name'),
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id')
->whereIn('class_progress_reports.class_section_id', $sectionIds)
->orderByDesc('class_progress_reports.week_start')
->get();
}
/**
* CI `groupReportsByWeek` — keys `"{week_start}:{class_section_id}"`.
*
* @param iterable<int, ClassProgressReport> $rows
* @return array<string, array<string, mixed>>
*/
public function groupReportsByWeek(iterable $rows): array
{
$statusOptions = (array) config('progress.status_options', []);
$reportGroups = [];
foreach ($rows as $row) {
$statusLabel = $statusOptions[$row->status] ?? 'Unknown';
$row->setAttribute('status_label', $statusLabel);
$weekStart = $row->week_start instanceof \Carbon\CarbonInterface
? $row->week_start->format('Y-m-d')
: (string) $row->week_start;
$sectionId = (int) $row->class_section_id;
if ($weekStart === '' || $sectionId === 0) {
continue;
}
$key = $weekStart.':'.$sectionId;
if (! isset($reportGroups[$key])) {
$reportGroups[$key] = [
'week_start' => $weekStart,
'week_end' => $row->week_end instanceof \Carbon\CarbonInterface
? $row->week_end->format('Y-m-d')
: (string) ($row->week_end ?? ''),
'class_section_name' => $row->class_section_name ?? '',
'class_section_id' => $sectionId,
'reports' => [],
];
}
$subject = (string) ($row->subject ?? '');
if ($subject === '') {
continue;
}
$reportGroups[$key]['reports'][$subject] = (new \App\Http\Resources\ClassProgress\ClassProgressReportResource($row))->toArray(request());
}
return $reportGroups;
}
/**
* Weekly batch for a section/week (CI parent `view`, all subjects).
*
* @return list<ClassProgressReport>
*/
public function weeklyReportsForSectionWeek(int $classSectionId, string $weekStartYmd): array
{
return ClassProgressReport::query()
->with(['classSection', 'teacher'])
->where('class_section_id', $classSectionId)
->whereDate('week_start', $weekStartYmd)
->orderBy('subject')
->get()
->all();
}
/**
* @return array<int, list<array{id:int,name:string}>>
*/
public function attachmentMapForReportIds(array $reportIds): array
{
return $this->classProgressQuery->attachmentMap($reportIds);
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Services\Parents;
use App\Models\Configuration;
use App\Services\ApplicationUrlService;
use App\Models\ReportCardAcknowledgement;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* CodeIgniter {@see \App\Controllers\ParentReportCardController} parity (read + acknowledgement).
*/
class ParentReportCardService
{
public function __construct(
private PrimaryParentUserResolver $primaryParentResolver,
private ApplicationUrlService $urls,
) {}
/**
* Resolve term from query string with configuration fallbacks (CI index/view).
*
* @return array{0: string, 1: string}
*/
public function termFromRequest(?string $schoolYearGet, ?string $semesterGet): array
{
$schoolYear = trim((string) ($schoolYearGet ?? ''));
$semester = trim((string) ($semesterGet ?? ''));
if ($schoolYear === '') {
$schoolYear = trim((string) (Configuration::getConfigValueByKey('school_year') ?? ''));
}
if ($semester === '') {
$semester = trim((string) (Configuration::getConfigValueByKey('semester') ?? ''));
}
return [$schoolYear, $semester];
}
/**
* CI sign() — current configuration term only (no GET overrides).
*
* @return array{0: string, 1: string}
*/
public function termFromConfiguration(): array
{
$schoolYear = trim((string) (Configuration::getConfigValueByKey('school_year') ?? ''));
$semester = trim((string) (Configuration::getConfigValueByKey('semester') ?? ''));
return [$schoolYear, $semester];
}
public function resolvePrimaryParentUserId(?User $user): ?int
{
return $this->primaryParentResolver->resolveFromUser($user);
}
/**
* CI index() student rows for parent + optional student_class term filters.
*
* @return list<array<string, mixed>>
*/
public function studentsForTerm(int $primaryParentUserId, string $schoolYear, string $semester): array
{
if ($primaryParentUserId <= 0) {
return [];
}
$q = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name')
->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('s.parent_id', $primaryParentUserId)
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC');
if ($schoolYear !== '') {
$q->where('sc.school_year', $schoolYear);
}
if ($semester !== '') {
$q->where('sc.semester', $semester);
}
return $q->get()->map(static fn ($r) => (array) $r)->all();
}
/**
* @param list<int> $studentIds
* @return array<int, array<string, mixed>>
*/
public function acknowledgementMap(int $primaryParentUserId, array $studentIds, string $schoolYear, string $semester): array
{
if ($primaryParentUserId <= 0 || $studentIds === []) {
return [];
}
$rows = ReportCardAcknowledgement::query()
->where('parent_id', $primaryParentUserId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->whereIn('student_id', $studentIds)
->get();
$map = [];
foreach ($rows as $row) {
$map[(int) $row->student_id] = $row->toArray();
}
return $map;
}
public function studentBelongsToPrimaryParent(int $studentId, int $primaryParentUserId): bool
{
$pid = DB::table('students')->where('id', $studentId)->value('parent_id');
return $pid !== null && (int) $pid === $primaryParentUserId;
}
/**
* @param array<string, mixed> $values viewed_at, signed_at, signed_name, signer_ip, …
*/
public function touchAcknowledgement(
int $primaryParentUserId,
int $studentId,
string $schoolYear,
string $semester,
array $values,
): void {
$criteria = [
'parent_id' => $primaryParentUserId,
'student_id' => $studentId,
'school_year' => $schoolYear,
'semester' => $semester,
];
$existing = ReportCardAcknowledgement::query()->where($criteria)->first();
if ($existing) {
$existing->fill($values);
$existing->save();
return;
}
ReportCardAcknowledgement::query()->create(array_merge($criteria, $values));
}
public function reportCardUrl(int $studentId, string $schoolYear, string $semester): string
{
$query = [];
if ($schoolYear !== '') {
$query['school_year'] = $schoolYear;
}
if ($semester !== '') {
$query['semester'] = $semester;
}
$base = $this->urls->studentReportCardUrl($studentId);
return $query === [] ? $base : $base.'?'.http_build_query($query);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Services\Parents;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Maps the logged-in parent-family account to the primary parent's user id
* (stored on {@see \App\Models\Student::$parent_id}), matching CodeIgniter
* {@see \App\Controllers\ParentReportCardController::resolvePrimaryParentId}.
*/
final class PrimaryParentUserResolver
{
public function resolveFromUser(?User $user): ?int
{
if ($user === null) {
return null;
}
return $this->resolveFromCredentials((int) $user->id, (string) ($user->user_type ?? ''));
}
/**
* @param int $userId Session user id (may be secondary/tertiary login).
*/
public function resolveFromCredentials(int $userId, string $userType): ?int
{
$userType = strtolower(trim($userType));
if ($userType === 'primary') {
return $userId > 0 ? $userId : null;
}
if ($userType === 'secondary') {
$row = DB::table('parents')
->select('parent_id')
->where('secondparent_user_id', $userId)
->first();
return $row ? (int) ($row->parent_id ?? 0) ?: null : null;
}
if ($userType === 'tertiary') {
$row = DB::table('authorized_users')
->select('user_id')
->where('authorized_user_id', $userId)
->first();
return $row ? (int) ($row->user_id ?? 0) ?: null : null;
}
return $userId > 0 ? $userId : null;
}
}
@@ -3,10 +3,16 @@
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
class PaymentEnrollmentEventService
{
public function __construct(
private ApplicationUrlService $urls,
) {
}
public function buildEventData(
int $parentId,
array $studentIds,
@@ -16,7 +22,7 @@ class PaymentEnrollmentEventService
): array {
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
$semester = $semester ?: Configuration::getConfig('semester');
$portalLink = $portalLink ?: url('/login');
$portalLink = $portalLink ?: $this->urls->webLoginUrl();
$parent = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
@@ -6,6 +6,7 @@ use App\Models\Configuration;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use App\Support\MailHtml;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -255,14 +256,7 @@ class PaymentNotificationService
</div>
HTML;
if (view()->exists('emails._wrap_layout')) {
$body = view('emails._wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => true]);
} else {
$body = $bodyHtml;
}
$body = MailHtml::document('Monthly Tuition Reminder', $bodyHtml);
return [$subject, $body];
}
@@ -8,6 +8,7 @@ use App\Models\Invoice;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use App\Support\MailHtml;
use DateTimeImmutable;
use DateTimeZone;
@@ -84,14 +85,7 @@ class PaymentTestNotificationService
. "<li><strong>Maximum installments available:</strong> {$installmentInfo['max_installments']}</li>"
. "</ul>";
if (view()->exists('emails._wrap_layout')) {
$body = view('emails._wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => true]);
} else {
$body = $bodyHtml;
}
$body = MailHtml::document('Monthly Tuition Reminder', $bodyHtml);
$ok = $this->emailService->send($email, $subject, (string) $body, 'finance');
if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) {
@@ -3,11 +3,17 @@
namespace App\Services\Payments;
use App\Models\Payment;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class PaypalPaymentService
{
public function __construct(
private ApplicationUrlService $urls,
) {
}
public function getRedirectUrl(): string
{
return (string) (config('services.paypal.payment_url')
@@ -50,8 +56,8 @@ class PaypalPaymentService
->setTransactions([$transaction]);
$redirectUrls = new \PayPal\Api\RedirectUrls();
$redirectUrls->setReturnUrl(url('/api/v1/finance/paypal/execute'))
->setCancelUrl(url('/api/v1/finance/paypal/cancel'));
$redirectUrls->setReturnUrl($this->urls->paypalExecuteReturnUrl())
->setCancelUrl($this->urls->paypalCancelUrl());
$paypalPayment->setRedirectUrls($redirectUrls);
$paypalPayment->create($apiContext);
@@ -0,0 +1,557 @@
<?php
namespace App\Services\PrintRequests;
use App\Models\AdminNotificationSubject;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\PrintRequest;
use App\Models\User;
use App\Services\ApplicationUrlService;
use App\Services\Email\EmailDispatchService;
use Carbon\CarbonInterface;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* CodeIgniter {@see \App\Controllers\PrintRequests} parity (SPA JSON + uploads).
*/
class PrintRequestsPortalService
{
public function __construct(
private EmailDispatchService $mail,
private ApplicationUrlService $urls,
) {}
public function storageDirectory(): string
{
return storage_path('app/private/print_requests');
}
public function ensureStorageDirectoryExists(): void
{
$dir = $this->storageDirectory();
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
}
/**
* @return list<string>
*/
public function candidateAbsolutePaths(string $storedName): array
{
$safe = basename(trim($storedName));
return [
$this->storageDirectory().DIRECTORY_SEPARATOR.$safe,
public_path('uploads/print_requests/'.$safe),
];
}
public function resolveExistingFilePath(string $storedName): ?string
{
if (trim($storedName) === '') {
return null;
}
foreach ($this->candidateAbsolutePaths($storedName) as $path) {
if (is_file($path)) {
return $path;
}
}
return null;
}
/**
* @return array{sundays: list<string>, times: list<string>}
*/
public function buildRequiredByOptions(): array
{
$tz = new \DateTimeZone('America/Chicago');
$currentDate = new \DateTime('now', $tz);
$currentYear = (int) $currentDate->format('Y');
$endYear = ($currentDate->format('n') > 6) ? $currentYear + 1 : $currentYear;
$endDate = new \DateTime("first Sunday of June {$endYear}", $tz);
$endDate->modify('+1 week');
$sundays = [];
$date = new \DateTime('now', $tz);
$date->setTime(0, 0, 0);
if ($date->format('w') !== '0') {
$date->modify('next Sunday');
}
while ($date < $endDate) {
$sundays[] = $date->format('Y-m-d');
$date->modify('+1 week');
}
$times = [];
$start = new \DateTime('10:00', $tz);
$end = new \DateTime('13:00', $tz);
$interval = new \DateInterval('PT15M');
$period = new \DatePeriod($start, $interval, $end);
foreach ($period as $time) {
$times[] = $time->format('H:i');
}
return [
'sundays' => $sundays,
'times' => $times,
];
}
/**
* @return list<array<string, mixed>>
*/
public function teacherPrintRequests(int $teacherId): array
{
return DB::table('print_requests as pr')
->select('pr.*', 'admins.firstname as admin_firstname', 'admins.lastname as admin_lastname')
->leftJoin('users as admins', 'admins.id', '=', 'pr.admin_id')
->where('pr.teacher_id', $teacherId)
->orderBy('pr.id', 'desc')
->get()
->map(fn ($r) => $this->normalizePrintRequestRow((array) $r))
->all();
}
/**
* Admin queue ordering per CI raw SQL.
*
* @return list<array<string, mixed>>
*/
public function adminPrintRequests(): array
{
return DB::table('print_requests as pr')
->select(
'pr.*',
'u.firstname',
'u.lastname',
'cs.class_section_name',
'admins.firstname as admin_firstname',
'admins.lastname as admin_lastname'
)
->leftJoin('users as u', 'u.id', '=', 'pr.teacher_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'pr.class_id')
->leftJoin('users as admins', 'admins.id', '=', 'pr.admin_id')
->orderByRaw("CASE
WHEN pr.status = 'not_assigned' THEN 1
WHEN pr.status = 'assigned' THEN 2
WHEN pr.status = 'done' THEN 3
WHEN pr.status = 'delivered' THEN 4
ELSE 5
END ASC")
->orderBy('pr.required_by', 'asc')
->get()
->map(fn ($r) => $this->normalizePrintRequestRow((array) $r))
->all();
}
public function defaultClassSectionIdForTeacher(int $teacherId): ?int
{
$row = DB::table('teacher_class as tc')
->join('classSection as cs', 'tc.class_section_id', '=', 'cs.class_section_id')
->where('tc.teacher_id', $teacherId)
->whereNotNull('tc.class_section_id')
->select('cs.class_section_id')
->orderBy('tc.class_section_id')
->first();
return $row ? (int) $row->class_section_id : null;
}
/**
* @param array<string, mixed> $requestData merged row after update for notifications
*/
public function notifyAdminsForPrintRequest(int $printRequestId, array $requestData, string $event = 'created'): void
{
try {
$event = strtolower(trim($event));
if (
! Schema::hasTable('admin_notification_subjects')
|| ! Schema::hasTable('notifications')
|| ! Schema::hasTable('user_notifications')
) {
return;
}
$adminIds = AdminNotificationSubject::query()
->where('subject', 'print_requests')
->pluck('admin_id')
->map(fn ($id) => (int) $id)
->unique()
->filter(fn ($id) => $id > 0)
->values()
->all();
if ($adminIds === []) {
return;
}
$teacherId = (int) ($requestData['teacher_id'] ?? 0);
$teacherName = '';
if ($teacherId > 0) {
$teacher = User::query()->find($teacherId);
if ($teacher) {
$teacherName = trim(($teacher->firstname ?? '').' '.($teacher->lastname ?? ''));
}
}
if ($teacherName === '') {
$teacherName = $teacherId > 0 ? ('Teacher #'.$teacherId) : 'Teacher';
}
$className = '';
$classId = $requestData['class_id'] ?? null;
if ($classId !== null && $classId !== '') {
$className = (string) (ClassSection::getClassSectionNameBySectionId((int) $classId) ?? '');
}
$eventMap = [
'created' => [
'title' => 'New Print Request',
'intro' => 'A new print request has been submitted.',
'lead' => 'New print request from ',
],
'updated' => [
'title' => 'Print Request Updated',
'intro' => 'A print request has been updated.',
'lead' => 'Updated print request from ',
],
'deleted' => [
'title' => 'Print Request Removed',
'intro' => 'A print request has been deleted.',
'lead' => 'Print request removed for ',
],
];
$eventConfig = $eventMap[$event] ?? $eventMap['created'];
$filePath = trim((string) ($requestData['file_path'] ?? ''));
$isCopyRequest = $filePath === '';
$messageParts = [$eventConfig['lead'].$teacherName];
if ($className !== '') {
$messageParts[] = 'Class: '.$className;
}
if (! empty($requestData['num_copies'])) {
$messageParts[] = 'Copies: '.(int) $requestData['num_copies'];
}
if (! empty($requestData['required_by'])) {
$messageParts[] = 'Needed by: '.$requestData['required_by'];
}
if (! empty($requestData['page_selection'])) {
$messageParts[] = 'Pages: '.$requestData['page_selection'];
}
if (! empty($requestData['pickup_method'])) {
$messageParts[] = 'Pickup: '.$requestData['pickup_method'];
}
$message = implode(' | ', $messageParts);
$actionUrl = $this->urls->spaAdminPrintRequestsUrl();
$payload = [
'title' => $eventConfig['title'],
'message' => $message,
'target_group' => 'admin',
'delivery_channels' => 'in_app,email',
'priority' => 'normal',
'status' => 'pending',
'action_url' => $isCopyRequest ? '' : $actionUrl,
'scheduled_at' => function_exists('utc_now') ? utc_now() : now(),
'school_year' => Configuration::getConfigValueByKey('school_year'),
'semester' => Configuration::getConfigValueByKey('semester'),
];
$notificationFields = Schema::getColumnListing('notifications');
if ($notificationFields !== []) {
$payload = array_intersect_key($payload, array_flip($notificationFields));
}
$notificationId = (int) DB::table('notifications')->insertGetId(array_merge($payload, [
'created_at' => now(),
'updated_at' => now(),
]));
if ($notificationId <= 0) {
return;
}
$userNotificationFields = Schema::getColumnListing('user_notifications');
$allowedUn = array_flip($userNotificationFields !== [] ? $userNotificationFields : []);
foreach ($adminIds as $adminId) {
$row = [
'notification_id' => $notificationId,
'user_id' => $adminId,
'is_read' => 0,
'delivered' => 0,
];
if ($allowedUn !== []) {
$row = array_intersect_key($row, $allowedUn);
}
DB::table('user_notifications')->insert($row);
}
$adminRows = User::query()
->select(['id', 'firstname', 'lastname', 'email'])
->whereIn('id', $adminIds)
->get();
$subject = $eventConfig['title'];
foreach ($adminRows as $adminRow) {
$email = trim((string) ($adminRow->email ?? ''));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$body = '<p>'.e($eventConfig['intro']).'</p>'
.'<p><strong>From:</strong> '.e($teacherName).'</p>'
.($className !== '' ? '<p><strong>Class:</strong> '.e($className).'</p>' : '')
.(! empty($requestData['num_copies']) ? '<p><strong>Copies:</strong> '.(int) $requestData['num_copies'].'</p>' : '')
.(! empty($requestData['required_by']) ? '<p><strong>Needed by:</strong> '.e((string) $requestData['required_by']).'</p>' : '')
.(! empty($requestData['page_selection']) ? '<p><strong>Pages:</strong> '.e((string) $requestData['page_selection']).'</p>' : '')
.(! empty($requestData['pickup_method']) ? '<p><strong>Pickup Method:</strong> '.e((string) $requestData['pickup_method']).'</p>' : '')
.($isCopyRequest ? '' : '<p><a href="'.e($actionUrl).'">View print requests</a></p>');
$this->mail->send($email, $subject, $body, 'notifications');
}
} catch (\Throwable $e) {
logger()->error('Print request notification failed: '.$e->getMessage());
}
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
public function normalizePrintRequestRow(array $row): array
{
foreach (['required_by'] as $key) {
if (! isset($row[$key])) {
continue;
}
$v = $row[$key];
if ($v instanceof CarbonInterface) {
$row[$key] = $v->format('Y-m-d H:i:s');
}
}
return $row;
}
/**
* @return array<string, list<string>>
*/
public static function allowedStatusTransitions(): array
{
return [
'not_assigned' => ['assigned'],
'assigned' => ['not_assigned', 'done'],
'done' => ['delivered'],
'delivered' => [],
];
}
public function teacherOwnsRequest(PrintRequest $request, int $teacherUserId): bool
{
return (int) $request->teacher_id === $teacherUserId;
}
public function teacherMayEditOrDelete(PrintRequest $request): bool
{
return in_array((string) $request->status, ['not_assigned', 'assigned'], true);
}
/**
* Teacher dashboard payload (parity with CI teacher_index).
*
* @return array{print_requests: list<array<string, mixed>>, sundays: list<string>, times: list<string>, class_id: int|null}
*/
public function teacherBootstrap(int $teacherId): array
{
$opts = $this->buildRequiredByOptions();
return [
'print_requests' => $this->teacherPrintRequests($teacherId),
'sundays' => $opts['sundays'],
'times' => $opts['times'],
'class_id' => $this->defaultClassSectionIdForTeacher($teacherId),
];
}
/**
* @param array<string, mixed> $data validated: class_id, page_selection?, num_copies, required_by, pickup_method
*/
public function storeTeacherUpload(array $data, UploadedFile $file, int $teacherId): PrintRequest
{
$this->ensureStorageDirectoryExists();
$ext = $file->getClientOriginalExtension();
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
$file->move($this->storageDirectory(), $stored);
$row = [
'teacher_id' => $teacherId,
'class_id' => (int) $data['class_id'],
'file_path' => $stored,
'page_selection' => isset($data['page_selection']) ? trim((string) $data['page_selection']) : null,
'num_copies' => (int) $data['num_copies'],
'required_by' => $data['required_by'],
'pickup_method' => (string) $data['pickup_method'],
'status' => 'not_assigned',
];
$model = PrintRequest::query()->create($row);
$payload = array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]);
$this->notifyAdminsForPrintRequest((int) $model->id, $payload, 'created');
return $model->fresh();
}
/**
* @param array<string, mixed> $data num_copies, required_by, pickup_method, page_selection?
*/
public function updateTeacherFields(PrintRequest $request, array $data, ?UploadedFile $newFile): PrintRequest
{
$update = [
'num_copies' => (int) $data['num_copies'],
'required_by' => $data['required_by'],
'pickup_method' => (string) $data['pickup_method'],
'page_selection' => isset($data['page_selection']) ? trim((string) $data['page_selection']) : null,
];
if ($newFile !== null) {
$this->ensureStorageDirectoryExists();
$old = trim((string) ($request->file_path ?? ''));
if ($old !== '') {
foreach ($this->candidateAbsolutePaths($old) as $abs) {
if (is_file($abs)) {
@unlink($abs);
break;
}
}
}
$ext = $newFile->getClientOriginalExtension();
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
$newFile->move($this->storageDirectory(), $stored);
$update['file_path'] = $stored;
}
$request->update($update);
$merged = array_merge($request->toArray(), $update, ['id' => $request->id]);
$this->notifyAdminsForPrintRequest((int) $request->id, $merged, 'updated');
return $request->fresh();
}
public function applyAdminStatus(PrintRequest $request, string $newStatus, int $adminUserId): PrintRequest
{
$current = (string) $request->status;
$allowed = self::allowedStatusTransitions();
if (! isset($allowed[$current]) || ! in_array($newStatus, $allowed[$current], true)) {
if ($current === $newStatus) {
return $request;
}
throw new \InvalidArgumentException('Invalid status transition.');
}
$data = ['status' => $newStatus];
if ($newStatus === 'assigned') {
$data['admin_id'] = $adminUserId;
} elseif ($newStatus === 'not_assigned') {
$data['admin_id'] = null;
}
$request->update($data);
return $request->fresh();
}
public function deleteTeacherRequest(PrintRequest $request): void
{
$this->notifyAdminsForPrintRequest((int) $request->id, $request->toArray(), 'deleted');
$fp = trim((string) ($request->file_path ?? ''));
if ($fp !== '') {
foreach ($this->candidateAbsolutePaths($fp) as $abs) {
if (is_file($abs)) {
@unlink($abs);
break;
}
}
}
$request->delete();
}
public function copyFromExisting(PrintRequest $source, int $teacherId): PrintRequest
{
$fp = trim((string) ($source->file_path ?? ''));
if ($fp === '' || $this->resolveExistingFilePath($fp) === null) {
throw new \InvalidArgumentException('The original file is unavailable for copying.');
}
$row = [
'teacher_id' => $teacherId,
'class_id' => (int) $source->class_id,
'file_path' => $fp,
'page_selection' => $source->page_selection,
'num_copies' => (int) $source->num_copies,
'required_by' => $source->required_by,
'pickup_method' => (string) $source->pickup_method,
'status' => 'not_assigned',
];
$model = PrintRequest::query()->create($row);
$this->notifyAdminsForPrintRequest((int) $model->id, array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]), 'created');
return $model->fresh();
}
/**
* Copy-center request without uploading a file (CI createCopy).
*
* @param array<string, mixed> $data class_id, num_copies, required_by, pickup_method
*/
public function storeHandCopy(array $data, int $teacherId): PrintRequest
{
$row = [
'teacher_id' => $teacherId,
'class_id' => (int) $data['class_id'],
'file_path' => '',
'page_selection' => null,
'num_copies' => (int) $data['num_copies'],
'required_by' => $data['required_by'],
'pickup_method' => (string) $data['pickup_method'],
'status' => 'not_assigned',
];
$model = PrintRequest::query()->create($row);
$this->notifyAdminsForPrintRequest((int) $model->id, array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]), 'created');
return $model->fresh();
}
/**
* Teacher may download own files; admins may download any.
*/
public function authorizeDownload(PrintRequest $request, int $userId, bool $userIsPrintAdmin): bool
{
if ($userIsPrintAdmin) {
return true;
}
return $this->teacherOwnsRequest($request, $userId);
}
public function absolutePathForDownload(PrintRequest $request): ?string
{
$fp = trim((string) ($request->file_path ?? ''));
if ($fp === '') {
return null;
}
return $this->resolveExistingFilePath($fp);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Services\PublicWinners;
use App\Models\ClassSection;
use App\Models\Competition;
use Illuminate\Support\Facades\DB;
/**
* CodeIgniter {@see \App\Controllers\WinnersController} — public published winners pages.
*/
class PublicWinnersPortalService
{
/**
* @return list<array<string, mixed>>
*/
public function publishedCompetitions(): array
{
return Competition::query()
->where('is_published', 1)
->orderByDesc('published_at')
->get()
->map(fn (Competition $c) => $this->normalizeCompetition($c))
->values()
->all();
}
/**
* @return array{
* competition: array<string, mixed>,
* winners_by_class: array<int|string, list<array<string, mixed>>>,
* section_map: array<int, string>,
* question_counts: array<int, int>
* }|null
*/
public function competitionPayload(int $id): ?array
{
$competition = Competition::query()
->where('is_published', 1)
->find($id);
if (! $competition) {
return null;
}
$winnerRows = DB::table('competition_winners as cw')
->select('cw.*', 's.firstname', 's.lastname', 'cs.class_section_name')
->leftJoin('students as s', 's.id', '=', 'cw.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'cw.class_section_id')
->where('cw.competition_id', $id)
->orderBy('cw.class_section_id', 'ASC')
->orderBy('cw.rank', 'ASC')
->get();
$sectionMap = [];
foreach (
ClassSection::query()
->select(['class_section_id', 'class_section_name'])
->get() as $section
) {
$sid = (int) ($section->class_section_id ?? 0);
if ($sid > 0) {
$sectionMap[$sid] = (string) ($section->class_section_name ?? (string) $sid);
}
}
$winnersByClass = [];
foreach ($winnerRows as $row) {
$arr = (array) $row;
$classId = (int) ($arr['class_section_id'] ?? 0);
$fname = (string) ($arr['firstname'] ?? '');
$lname = (string) ($arr['lastname'] ?? '');
$name = trim($fname.' '.$lname);
$arr['name'] = $name !== '' ? $name : ('Student #'.($arr['student_id'] ?? ''));
$winnersByClass[$classId][] = $arr;
}
$questionCounts = [];
$qRows = DB::table('competition_class_winners')
->select(['class_section_id', 'question_count'])
->where('competition_id', $id)
->get();
foreach ($qRows as $row) {
$arr = (array) $row;
$classId = (int) ($arr['class_section_id'] ?? 0);
if ($classId > 0 && isset($arr['question_count']) && $arr['question_count'] !== null) {
$questionCounts[$classId] = (int) $arr['question_count'];
}
}
return [
'competition' => $this->normalizeCompetition($competition),
'winners_by_class' => $winnersByClass,
'section_map' => $sectionMap,
'question_counts' => $questionCounts,
];
}
/**
* @return array<string, mixed>
*/
private function normalizeCompetition(Competition $c): array
{
$arr = $c->toArray();
foreach ($arr as $key => $value) {
if ($value instanceof \Carbon\CarbonInterface) {
$arr[$key] = $value->format('Y-m-d H:i:s');
}
}
return $arr;
}
}
@@ -5,14 +5,17 @@ namespace App\Services\Refunds;
use App\Models\Configuration;
use App\Models\Invoice;
use App\Models\Refund;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class RefundOverpaymentService
{
public function __construct(private RefundNotificationService $notifications)
{
public function __construct(
private RefundNotificationService $notifications,
private ApplicationUrlService $urls,
) {
}
public function recalculate(bool $triggerEvents = false, ?string $invoiceNumber = null, ?int $actorId = null): array
@@ -146,7 +149,7 @@ class RefundOverpaymentService
]);
if ($refund && $triggerEvents) {
$this->notifications->notifyPending($pid, $over, url('/login'));
$this->notifications->notifyPending($pid, $over, $this->urls->webLoginUrl());
}
return ['ok' => true, 'message' => 'Overpayment detected and refund created.'];
@@ -250,7 +253,7 @@ class RefundOverpaymentService
if ($refund) {
$created[] = (int) $refund->id;
if ($triggerEvents) {
$this->notifications->notifyPending($pid, $over, url('/login'));
$this->notifications->notifyPending($pid, $over, $this->urls->webLoginUrl());
}
}
} elseif (in_array($existing->status, [Refund::STATUS_PENDING, Refund::STATUS_PARTIAL], true)) {
@@ -5,6 +5,7 @@ namespace App\Services\Refunds;
use App\Models\Configuration;
use App\Models\Refund;
use App\Models\User;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -15,7 +16,8 @@ class RefundRequestService
public function __construct(
private RefundPolicyService $policyService,
private RefundSummaryService $summaryService,
private RefundNotificationService $notifications
private RefundNotificationService $notifications,
private ApplicationUrlService $urls,
) {
}
@@ -49,7 +51,7 @@ class RefundRequestService
}
if (($result['refund_amount'] ?? 0) > 0) {
$this->notifications->notifyPending($parentId, (float) $result['refund_amount'], url('/login'));
$this->notifications->notifyPending($parentId, (float) $result['refund_amount'], $this->urls->webLoginUrl());
}
return [
@@ -114,7 +116,7 @@ class RefundRequestService
return ['ok' => false, 'message' => 'Failed to create refund request.'];
}
$this->notifications->notifyPending($parentId, $amount, url('/login'));
$this->notifications->notifyPending($parentId, $amount, $this->urls->webLoginUrl());
return [
'ok' => true,
@@ -3,33 +3,31 @@
namespace App\Services\Reimbursements;
use App\Models\ReimbursementBatchAdminFile;
use App\Services\ApplicationUrlService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use RuntimeException;
class ReimbursementFileService
{
public function __construct(
private ApplicationUrlService $urls
) {
}
public function receiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
$safe = basename(trim($filename));
return $safe !== '' ? url('/api/v1/files/reimbursements/' . $safe) : null;
return $this->urls->forReimbursementStorage($filename);
}
public function expenseReceiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
$safe = basename(trim($filename));
return $safe !== '' ? url('/api/v1/files/receipts/' . $safe) : null;
return $this->urls->forReceiptStorage($filename);
}
public function adminFileUrl(string $filename, string $mode = 'inline'): string
{
return url('/api/v1/finance/reimbursements/batches/admin-files/' . rawurlencode($filename) . '/' . $mode);
return $this->urls->forReimbursementAdminCheckFile($filename, $mode);
}
public function storeReceipt(?UploadedFile $file): ?string
@@ -2,6 +2,7 @@
namespace App\Services\Reports;
use App\Support\MailHtml;
use Dompdf\Dompdf;
use Dompdf\Options;
@@ -57,10 +58,9 @@ class SlipPrinterPdfService
$hMm = $dynHeightMm;
}
$html = view('slips/slip_pdf', [
'entry' => $data,
'lines' => $lines,
'page' => ['w_mm' => $wMm, 'h_mm' => $hMm],
$html = MailHtml::slipPdfHtml($data, $lines, [
'w_mm' => $wMm,
'h_mm' => $hMm,
]);
$optionsObj = new Options();
+3 -5
View File
@@ -315,10 +315,8 @@ class AccountEventService
private function renderEmail(string $viewName, array $data, string $fallbackTitle): string
{
try {
return view($viewName, $data, ['saveData' => true]);
} catch (\Throwable $e) {
return '<p>' . e($fallbackTitle) . '</p>';
}
Log::debug('Skipping Blade email template', ['view' => $viewName]);
return '<p>' . e($fallbackTitle) . '</p>';
}
}
+15 -15
View File
@@ -2,6 +2,7 @@
namespace App\Services\School;
use App\Services\ApplicationUrlService;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
@@ -10,7 +11,8 @@ class EnrollmentEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
private UserNotificationDispatchService $notifier,
private ApplicationUrlService $urls,
) {
}
@@ -22,7 +24,7 @@ class EnrollmentEventService
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Admission Application Under Review',
];
@@ -51,7 +53,7 @@ class EnrollmentEventService
'studentName' => $studentName,
'amountDue' => $parentData['amount'] ?? '',
'dueDate' => $parentData['due_date'] ?? '',
'portalLink' => $parentData['paymentLink'] ?? ($parentData['portalLink'] ?? url('/login')),
'portalLink' => $parentData['paymentLink'] ?? ($parentData['portalLink'] ?? $this->urls->webLoginUrl()),
'title' => 'Admission Decision',
];
@@ -86,7 +88,7 @@ class EnrollmentEventService
'teacherName' => $parentData['teacher_name'] ?? ($first['teacherName'] ?? ''),
'startDate' => $parentData['start_date'] ?? ($first['startDate'] ?? ''),
'students' => count($studentsDetails) > 1 ? $studentsDetails : null,
'portalLink' => url('/login'),
'portalLink' => $this->urls->webLoginUrl(),
'title' => 'Enrollment Confirmation',
];
@@ -120,7 +122,7 @@ class EnrollmentEventService
$emailData = [
'parentName' => $parentName,
'studentName' => $parentData['student_name'] ?? $studentName,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Withdrawal Under Review',
];
@@ -148,7 +150,7 @@ class EnrollmentEventService
'parentName' => $parentName,
'studentName' => $studentName,
'refundAmount' => $parentData['amount'] ?? '',
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Refund Pending',
];
@@ -178,7 +180,7 @@ class EnrollmentEventService
'studentName' => $studentName,
'withdrawalDate' => $parentData['withdrawal_date'] ?? ($parentData['date'] ?? ''),
'withdrawals' => !empty($withdrawals) ? $withdrawals : null,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Withdrawal Confirmed',
];
@@ -207,7 +209,7 @@ class EnrollmentEventService
'parentName' => $parentName,
'studentName' => $studentName,
'students' => $studentsWithReasons ?: null,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Admission Decision',
];
@@ -234,7 +236,7 @@ class EnrollmentEventService
$emailData = [
'parentName' => $parentName,
'studentName' => $studentName,
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Waitlist Update',
];
@@ -254,7 +256,7 @@ class EnrollmentEventService
'parentName' => $parentName,
'schoolYear' => $parentData['school_year'] ?? '',
'changes' => $parentData['changes'] ?? [],
'portalLink' => $parentData['portalLink'] ?? url('/login'),
'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(),
'title' => 'Enrollment Status Update',
];
@@ -409,10 +411,8 @@ class EnrollmentEventService
private function renderEmail(string $viewName, array $data, string $fallbackTitle): string
{
try {
return view($viewName, $data, ['saveData' => true]);
} catch (\Throwable $e) {
return '<p>' . e($fallbackTitle) . '</p>';
}
Log::debug('Skipping Blade email template', ['view' => $viewName]);
return '<p>' . e($fallbackTitle) . '</p>';
}
}
+8 -8
View File
@@ -2,6 +2,7 @@
namespace App\Services\School;
use App\Services\ApplicationUrlService;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\Log;
@@ -10,7 +11,8 @@ class PaymentEventService
{
public function __construct(
private EmailService $emailService,
private UserNotificationDispatchService $notifier
private UserNotificationDispatchService $notifier,
private ApplicationUrlService $urls,
) {
}
@@ -65,7 +67,7 @@ class PaymentEventService
'installmentSeq' => (int) ($data['installment_seq'] ?? 1),
'invoiceDiscount' => $invoiceDiscountRaw > 0 ? $fmtMoney($invoiceDiscountRaw) : null,
'parentYearDiscountTotal' => $parentYearDiscountTotalRaw > 0 ? $fmtMoney($parentYearDiscountTotalRaw) : null,
'portalLink' => url('/login'),
'portalLink' => $this->urls->webLoginUrl(),
'students' => $studentdata,
];
@@ -165,7 +167,7 @@ class PaymentEventService
'postBalance' => $postBalance,
'createdAt' => $createdAt,
'dueDate' => $dueDate !== '' ? $dueDate : null,
'portalLink' => $data['portal_link'] ?? url('/login'),
'portalLink' => $data['portal_link'] ?? $this->urls->webLoginUrl(),
'invoiceLink' => $data['invoice_link'] ?? null,
'students' => $studentdata,
];
@@ -214,10 +216,8 @@ class PaymentEventService
private function renderEmail(string $viewName, array $data, string $fallbackTitle): string
{
try {
return view($viewName, $data, ['saveData' => true]);
} catch (\Throwable $e) {
return '<p>' . e($fallbackTitle) . '</p>';
}
Log::debug('Skipping Blade email template', ['view' => $viewName]);
return '<p>' . e($fallbackTitle) . '</p>';
}
}
@@ -3,15 +3,17 @@
namespace App\Services\Settings\SchoolCalendar;
use App\Models\User;
use App\Services\ApplicationUrlService;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;
class SchoolCalendarNotificationService
{
public function __construct(private EmailService $emailService)
{
public function __construct(
private EmailService $emailService,
private ApplicationUrlService $urls,
) {
}
public function notify(array $event, array $targets): array
@@ -56,7 +58,7 @@ class SchoolCalendarNotificationService
private function buildBody(array $event): string
{
$calendarUrl = URL::to('/administrator/calendar_view');
$calendarUrl = $this->urls->spaAdministratorCalendarViewUrl();
$title = trim((string) ($event['title'] ?? 'School Calendar Update'));
$date = (string) ($event['date'] ?? '');
$description = trim((string) ($event['description'] ?? ''));
@@ -2,6 +2,7 @@
namespace App\Services\Teachers;
use App\Services\ApplicationUrlService;
use App\Services\Staff\StaffTimeOffLinkService;
use App\Models\StaffAttendance;
use App\Models\TeacherClass;
@@ -15,7 +16,8 @@ class TeacherAbsenceService
public function __construct(
private TeacherConfigService $configService,
private SemesterRangeService $semesterRangeService,
private StaffTimeOffLinkService $staffTimeOffLinkService
private StaffTimeOffLinkService $staffTimeOffLinkService,
private ApplicationUrlService $urls,
) {
}
@@ -253,7 +255,7 @@ class TeacherAbsenceService
'origin' => 'teacher portal',
]);
$notifyUrl = url('/timeoff/notify/' . rawurlencode($token));
$notifyUrl = $this->urls->timeoffNotifyUrl($token);
$body .= '<p style="margin-top:16px;">Click <a href="' . e($notifyUrl) . '">Send confirmation email to '
. e($fullName)
. '</a> so the staff member is notified automatically. This link expires in 14 days.</p>';
@@ -30,8 +30,6 @@ class WhatsappInviteEmailService
$sections = (array) ($payload['sections'] ?? []);
$links = array_values(array_unique(array_filter((array) ($payload['links'] ?? []))));
$parent = $payload['parent'] ?? null;
$schoolYear = $payload['schoolYear'] ?? null;
$semester = $payload['semester'] ?? null;
$items = $this->buildItems($sections, $links);
if (empty($items)) {
@@ -43,29 +41,13 @@ class WhatsappInviteEmailService
? 'Your WhatsApp Group Links (Multiple Classes)'
: 'Your WhatsApp Group Link';
$viewData = [
'parent' => $parent,
'items' => $items,
'sections' => $sections,
'links' => $links,
'schoolYear' => $schoolYear,
'semester' => $semester,
'title' => $subject,
'teacherNames' => $payload['teachers'] ?? [],
];
$bodyHtml = '';
try {
$bodyHtml = view('emails/whatsapp_invite', $viewData, ['saveData' => true]);
} catch (\Throwable $e) {
$lines = ['Assalamu Alaikum,', '', 'Here are your WhatsApp link(s):'];
foreach ($items as $it) {
$lines[] = ' - ' . $it['class_section_name'] . ': ' . $it['invite_link'];
}
$lines[] = '';
$lines[] = 'Jazakumu-llahu khayran.';
$bodyHtml = nl2br(implode("\n", $lines));
$lines = ['Assalamu Alaikum,', '', 'Here are your WhatsApp link(s):'];
foreach ($items as $it) {
$lines[] = ' - '.$it['class_section_name'].': '.$it['invite_link'];
}
$lines[] = '';
$lines[] = 'Jazakumu-llahu khayran.';
$bodyHtml = nl2br(implode("\n", $lines));
$ok = $this->emailService->send($to, $subject, $bodyHtml, 'notifications', $cc);