update controllers logic
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user