security fix and fix parent pages
API CI/CD / Validate (composer + pint) (push) Successful in 3m7s
API CI/CD / Test (PHPUnit) (push) Failing after 5m46s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-06 02:14:16 -04:00
parent 39228168c8
commit 90f9857b06
43 changed files with 4323 additions and 132 deletions
@@ -96,7 +96,9 @@ class ClassProgressController extends BaseApiController
$filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
$filters['semester'] = $filters['semester'] ?? ($meta['semester'] ?? null);
if ($this->isAdminProgressAliasRoute($request)) {
$filters['group_by_week'] = true;
}
$paginator = $this->queryService->listReports($auth, $filters);
if (! empty($filters['group_by_week'])) {
@@ -262,6 +264,12 @@ class ClassProgressController extends BaseApiController
|| $request->is('api/v1/teacher/class-progress-view');
}
private function isAdminProgressAliasRoute(ClassProgressIndexRequest $request): bool
{
return $request->is('api/v1/administrator/progress')
|| $request->is('api/v1/admin/progress');
}
/**
* @param list<string> $roles
*/
@@ -28,7 +28,7 @@ class FamilyAdminController extends BaseApiController
public function index(FamilyAdminIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
$schoolYear = $this->requestedSchoolYear($request, $payload);
$data = $this->queryService->adminIndexData(
$payload['student_id'] ?? null,
@@ -63,7 +63,7 @@ class FamilyAdminController extends BaseApiController
public function card(FamilyAdminCardRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
$schoolYear = $this->requestedSchoolYear($request, $payload);
$family = $this->queryService->familyCard(
$payload['student_id'] ?? null,
@@ -79,6 +79,26 @@ class FamilyAdminController extends BaseApiController
return $this->success(new FamilyResource($family));
}
private function requestedSchoolYear($request, array $payload): string
{
$candidates = [
$payload['school_year'] ?? null,
$request->query('school_year'),
$request->header('X-School-Year'),
$request->header('X-Selected-School-Year'),
Configuration::getConfig('school_year'),
];
foreach ($candidates as $candidate) {
$value = trim((string) ($candidate ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
{
$payload = $request->validated();
@@ -34,6 +34,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -286,6 +287,59 @@ class ReimbursementController extends BaseApiController
]);
}
public function createContext(Request $request): JsonResponse
{
$expenseId = (int) $request->query('expense_id', 0);
$prefillAmount = null;
$expense = null;
if ($expenseId > 0) {
$expense = DB::table('expenses')
->select('id', 'amount', 'description', 'purchased_by', 'receipt_path', 'retailor')
->where('id', $expenseId)
->first();
if ($expense) {
$prefillAmount = $expense->amount ?? null;
}
}
$data = $this->queryService->index([
'school_year' => $request->query('school_year', $this->context->getSchoolYear()),
'semester' => $request->query('semester', $this->context->getSemester()),
]);
return response()->json([
'ok' => true,
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
'expense_id' => $expenseId ?: null,
'expense' => $expense ? (array) $expense : null,
'prefill_amount' => $prefillAmount,
]);
}
public function editData(int $id): JsonResponse
{
$reimb = Reimbursement::query()->find($id);
if (! $reimb) {
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
}
$data = $this->queryService->index([
'school_year' => $reimb->school_year ?? $this->context->getSchoolYear(),
'semester' => $reimb->semester ?? $this->context->getSemester(),
]);
$payload = $reimb->toArray();
$payload['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
return response()->json([
'ok' => true,
'reimbursement' => new ReimbursementResource($payload),
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
'receipt_url' => $payload['receipt_url'],
]);
}
public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse
{
$payload = $request->validated();
@@ -9,6 +9,7 @@ use App\Http\Requests\Inventory\InventoryMovementStoreRequest;
use App\Http\Requests\Inventory\InventoryMovementUpdateRequest;
use App\Http\Resources\Inventory\InventoryMovementResource;
use App\Services\Inventory\InventoryMovementService;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
@@ -23,9 +24,65 @@ class InventoryMovementController extends BaseApiController
{
$rows = $this->movements->list($request->validated());
return $this->success([
return $this->success(array_merge([
'movements' => InventoryMovementResource::collection($rows),
]);
], $this->movementFormPayload()));
}
public function editForm(int $id): JsonResponse
{
$movement = collect($this->movements->list(['include_voided' => 1]))
->first(fn ($row) => (int) ($row['id'] ?? 0) === $id);
if (! $movement) {
return $this->error('Movement not found.', Response::HTTP_NOT_FOUND);
}
return $this->success(array_merge($this->movementFormPayload(), [
'movement' => new InventoryMovementResource($movement),
]));
}
private function movementFormPayload(): array
{
$items = DB::table('inventory_items')
->select('id', 'name', 'type', 'quantity')
->orderBy('name')
->get()
->map(fn ($row) => (array) $row)
->all();
$teachers = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->orderBy('firstname')
->orderBy('lastname')
->get()
->map(fn ($row) => (array) $row)
->all();
$students = DB::table('students')
->select('id', 'firstname', 'lastname')
->orderBy('firstname')
->orderBy('lastname')
->get()
->map(fn ($row) => (array) $row)
->all();
$classSections = DB::table('classSection')
->select('class_section_id as id', 'class_section_id', 'class_section_name')
->orderBy('class_section_name')
->get()
->map(fn ($row) => (array) $row)
->all();
return [
'items' => $items,
'teachers' => $teachers,
'students' => $students,
'classSections' => $classSections,
'movementTypes' => ['initial', 'in', 'out', 'adjust', 'distribution'],
'semesters' => ['Fall', 'Spring'],
];
}
public function store(InventoryMovementStoreRequest $request): JsonResponse
@@ -37,11 +37,67 @@ class WhatsappController extends BaseApiController
public function index(WhatsappLinkIndexRequest $request): JsonResponse
{
$links = $this->linkService->paginate($request->validated());
$payload = $request->validated();
$schoolYear = $this->context->schoolYear($payload['school_year'] ?? null);
$semester = array_key_exists('semester', $payload)
? (string) ($payload['semester'] ?? '')
: $this->context->semester();
$payload['school_year'] = $schoolYear;
$payload['semester'] = $semester;
$payload['per_page'] = max((int) ($payload['per_page'] ?? 200), 200);
$links = $this->linkService->paginate($payload);
$collection = new WhatsappGroupLinkCollection($links);
$linkRows = $collection->toArray($request);
$sectionsRaw = $this->contactService->listParentContactsByClass($schoolYear, $semester);
$sections = WhatsappClassSectionContactResource::collection($sectionsRaw)->resolve($request);
$linksBySection = [];
foreach ($linkRows as $link) {
$sid = (int) ($link['class_section_id'] ?? 0);
if ($sid > 0) {
$linksBySection[(string) $sid] = $link;
}
}
$parents = [];
foreach ($sections as $section) {
foreach (($section['parents'] ?? []) as $parent) {
$primaryId = (int) ($parent['primary_id'] ?? 0);
if ($primaryId > 0 && ! isset($parents['primary:'.$primaryId])) {
[$lastname, $firstname] = $this->splitDisplayName((string) ($parent['primary_name'] ?? ''));
$parents['primary:'.$primaryId] = [
'id' => $primaryId,
'firstname' => $firstname,
'lastname' => $lastname,
'email' => (string) ($parent['primary_email'] ?? ''),
'source' => 'primary',
];
}
$secondId = (int) ($parent['second_id'] ?? 0);
if ($secondId > 0 && ! isset($parents['second:'.$secondId])) {
[$lastname, $firstname] = $this->splitDisplayName((string) ($parent['second_name'] ?? ''));
$parents['second:'.$secondId] = [
'id' => $secondId,
'firstname' => $firstname,
'lastname' => $lastname,
'email' => (string) ($parent['second_email'] ?? ''),
'source' => 'parents',
];
}
}
}
return $this->success([
'links' => $collection->toArray($request),
'schoolYear' => $schoolYear,
'semester' => $semester,
'links' => $linkRows,
'linksBySection' => $linksBySection,
'sections' => $sections,
'parents' => array_values($parents),
'meta' => $collection->with($request)['meta'],
]);
}
@@ -93,6 +149,8 @@ class WhatsappController extends BaseApiController
$contacts = $this->contactService->listParentContacts($schoolYear, $semester);
return $this->success([
'schoolYear' => $schoolYear,
'semester' => $semester,
'contacts' => WhatsappParentContactResource::collection($contacts),
]);
}
@@ -160,6 +218,21 @@ class WhatsappController extends BaseApiController
], 'Membership saved.');
}
private function splitDisplayName(string $name): array
{
$name = trim($name);
if ($name === '') {
return ['', ''];
}
if (str_contains($name, ',')) {
[$last, $first] = array_pad(array_map('trim', explode(',', $name, 2)), 2, '');
return [$last, $first];
}
return [$name, ''];
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
@@ -13,17 +13,23 @@ use App\Http\Requests\Parents\ParentProfileUpdateRequest;
use App\Http\Requests\Parents\ParentRegistrationRequest;
use App\Http\Requests\Parents\ParentStudentUpdateRequest;
use App\Http\Resources\Invoices\InvoiceResource;
use App\Http\Resources\ClassProgress\ClassProgressShowResource;
use App\Http\Resources\Parents\ParentAttendanceResource;
use App\Http\Resources\Parents\ParentEmergencyContactResource;
use App\Http\Resources\Parents\ParentStudentResource;
use App\Models\ClassProgressReport;
use App\Services\Parents\ParentAttendanceService;
use App\Services\Parents\ParentEmergencyContactService;
use App\Services\Parents\ParentEnrollmentService;
use App\Services\Parents\ParentEventParticipationService;
use App\Services\Parents\ParentProgressQueryService;
use App\Services\Parents\ParentInvoiceService;
use App\Services\Parents\ParentProfileService;
use App\Services\Parents\ParentRegistrationService;
use App\Services\SchoolYears\SchoolYearContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ParentController extends BaseApiController
{
@@ -34,7 +40,9 @@ class ParentController extends BaseApiController
private ParentRegistrationService $registrationService,
private ParentEmergencyContactService $emergencyContactService,
private ParentProfileService $profileService,
private ParentEventParticipationService $eventService
private ParentEventParticipationService $eventService,
private ParentProgressQueryService $progressService,
private SchoolYearContextService $schoolYears
) {
parent::__construct();
}
@@ -92,6 +100,62 @@ class ParentController extends BaseApiController
]);
}
public function progress(Request $request): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$schoolYear = $this->schoolYears->currentSchoolYear($request->query('school_year'));
$semester = $request->query->has('semester')
? $this->schoolYears->currentSemester($request->query('semester'))
: null;
$sectionIds = $this->progressService->parentSectionIds($guard);
$reports = $this->progressService->reportsForSections($sectionIds, $schoolYear, $semester);
$groups = array_values($this->progressService->groupReportsByWeek($reports));
return response()->json([
'ok' => true,
'data' => [
'items' => $groups,
'groups' => $groups,
'students' => $this->progressService->parentStudents($guard),
'meta' => $this->schoolYears->options($schoolYear, $semester),
],
]);
}
public function progressDetail(Request $request, ClassProgressReport $class_progress): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
if (Gate::denies('view', $class_progress)) {
return response()->json(['ok' => false, 'status' => false, 'message' => 'Forbidden.'], 403);
}
$weekStart = $class_progress->week_start?->format('Y-m-d') ?? '';
$weeklyReports = $this->progressService->weeklyReportsForSectionWeek(
(int) $class_progress->class_section_id,
$weekStart
);
$data = (new ClassProgressShowResource([
'report' => $class_progress,
'weekly_reports' => $weeklyReports,
'status_options' => config('progress.status_options', []),
]))->toArray($request);
return response()->json([
'ok' => true,
'status' => true,
'data' => $data,
]);
}
public function updateEnrollments(ParentEnrollmentActionRequest $request): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
@@ -11,6 +11,8 @@ use App\Http\Resources\Reports\ReportCards\ReportCardClassSectionResource;
use App\Http\Resources\Reports\ReportCards\ReportCardCompletenessStudentResource;
use App\Http\Resources\Reports\ReportCards\ReportCardStudentMetaResource;
use App\Models\Configuration;
use App\Models\User;
use App\Services\Parents\ParentReportCardService;
use App\Services\Reports\ReportCards\ReportCardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -20,8 +22,10 @@ use Symfony\Component\HttpFoundation\Response;
class ReportCardsController extends BaseApiController
{
public function __construct(private ReportCardService $service)
{
public function __construct(
private ReportCardService $service,
private ParentReportCardService $parentReportCards,
) {
parent::__construct();
}
@@ -31,7 +35,8 @@ class ReportCardsController extends BaseApiController
$result = $this->service->meta(
$payload['school_year'] ?? null,
$payload['semester'] ?? null
$payload['semester'] ?? null,
$this->parentScopeId($request)
);
return $this->success([
@@ -88,6 +93,10 @@ class ReportCardsController extends BaseApiController
$validated = $validator->validated();
$studentId = (int) ($validated['student_id'] ?? 0);
if (! $this->parentCanAccessStudent($request, $studentId)) {
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
}
$schoolYear = $this->resolveSchoolYear($validated['school_year'] ?? null);
$semester = $this->resolveSemester($validated['semester'] ?? null);
@@ -98,6 +107,10 @@ class ReportCardsController extends BaseApiController
public function studentReport(ReportCardPdfRequest $request, int $studentId)
{
if (! $this->parentCanAccessStudent($request, $studentId)) {
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
}
try {
$result = $this->service->generateSingleReport($studentId, $request->validated());
} catch (\Throwable $e) {
@@ -122,6 +135,10 @@ class ReportCardsController extends BaseApiController
public function classReport(ReportCardPdfRequest $request, int $classSectionId)
{
if ($this->parentScopeId($request) !== null) {
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
}
try {
$result = $this->service->generateClassReport($classSectionId, $request->validated());
} catch (\Throwable $e) {
@@ -163,4 +180,45 @@ class ReportCardsController extends BaseApiController
return trim((string) (Configuration::getConfig('semester') ?? ''));
}
private function parentCanAccessStudent(Request $request, int $studentId): bool
{
$parentId = $this->parentScopeId($request);
if ($parentId === null) {
return true;
}
return $this->parentReportCards->studentBelongsToPrimaryParent($studentId, $parentId);
}
private function parentScopeId(Request $request): ?int
{
/** @var User|null $user */
$user = $request->user();
if (! $this->isParentFamilyUser($user)) {
return null;
}
$parentId = $this->parentReportCards->resolvePrimaryParentUserId($user);
return $parentId !== null && $parentId > 0 ? $parentId : null;
}
private function isParentFamilyUser(?User $user): bool
{
if (! $user) {
return false;
}
$roles = $user->roles()
->pluck('roles.name')
->map(fn ($name) => strtolower((string) $name))
->toArray();
if (in_array('parent', $roles, true)) {
return true;
}
return in_array(strtolower(trim((string) ($user->user_type ?? ''))), ['secondary', 'tertiary'], true);
}
}
@@ -59,6 +59,37 @@ class StickersController extends BaseApiController
]);
}
public function preview(StickerFormDataRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null);
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$students = $classSectionId > 0
? $this->queryService->listStudentsByClass($classSectionId, $schoolYear)
: $this->queryService->listStudentsForYear($schoolYear);
$rows = [];
foreach ($students as $student) {
$id = (int) ($student['id'] ?? $student['student_id'] ?? 0);
if ($id <= 0) {
continue;
}
$rows[] = [
'student_id' => $id,
'primary_count' => 1,
];
}
return $this->success([
'students' => $rows,
'totals' => [
'students' => count($rows),
'stickers' => count($rows),
],
]);
}
public function print(StickerPrintRequest $request)
{
try {
@@ -131,4 +131,12 @@ class SchoolYearController extends BaseApiController
'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')),
]);
}
public function closingReport(int $schoolYear): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->service->closingReport($schoolYear),
]);
}
}
@@ -43,7 +43,12 @@ class SchoolCalendarController extends BaseApiController
public function index(SchoolCalendarIndexRequest $request): JsonResponse
{
return $this->respondWithEvents($request->validated());
$filters = $request->validated();
if (! $this->canListEventsForAudience($filters['audience'] ?? null)) {
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
}
return $this->respondWithEvents($filters);
}
/**
@@ -52,6 +57,10 @@ class SchoolCalendarController extends BaseApiController
*/
public function teacherCalendarLegacy(SchoolCalendarIndexRequest $request): JsonResponse
{
if (! $this->canListEventsForAudience('teacher')) {
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
}
return $this->respondWithEvents([
...$request->validated(),
'audience' => 'teacher',
@@ -187,4 +196,56 @@ class SchoolCalendarController extends BaseApiController
return $userId;
}
private function canListEventsForAudience(?string $audience): bool
{
if ($this->hasAnyRole(['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
return true;
}
$audience = strtolower(trim((string) $audience));
if ($audience === 'parent') {
return $this->hasAnyRole(['parent']) || $this->hasUserType(['secondary', 'tertiary']);
}
if ($audience === 'teacher') {
return $this->hasAnyRole(['teacher']);
}
return false;
}
/**
* @param list<string> $roles
*/
private function hasAnyRole(array $roles): bool
{
$user = auth()->user();
if (! $user) {
return false;
}
$actual = $user->roles()
->pluck('roles.name')
->map(fn ($name) => strtolower((string) $name))
->toArray();
foreach ($roles as $role) {
if (in_array(strtolower($role), $actual, true)) {
return true;
}
}
return false;
}
/**
* @param list<string> $types
*/
private function hasUserType(array $types): bool
{
$type = strtolower(trim((string) (auth()->user()?->user_type ?? '')));
return in_array($type, array_map('strtolower', $types), true);
}
}
@@ -8,6 +8,7 @@ use App\Http\Requests\Staff\StaffStoreRequest;
use App\Http\Requests\Staff\StaffUpdateRequest;
use App\Http\Resources\Staff\StaffCollection;
use App\Http\Resources\Staff\StaffResource;
use App\Models\Role;
use App\Models\Staff;
use App\Models\User;
use App\Services\Staff\StaffCommandService;
@@ -45,6 +46,24 @@ class StaffController extends BaseApiController
]);
}
public function formOptions(): JsonResponse
{
$roles = Role::query()
->select('id', 'name')
->orderBy('priority')
->orderBy('name')
->get()
->map(fn (Role $role) => [
'id' => (int) $role->id,
'name' => (string) $role->name,
])
->all();
return $this->success([
'roles' => $roles,
]);
}
public function show(int $id): JsonResponse
{
$staff = $this->queryService->find($id);