fix unit tests as well as missing code
API CI/CD / Validate (composer + pint) (push) Successful in 2m7s
API CI/CD / Test (PHPUnit) (push) Failing after 2m23s
API CI/CD / Build frontend assets (push) Successful in 2m18s
API CI/CD / Security audit (push) Successful in 31s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-06-25 14:26:32 -04:00
parent fdfcd1f0e2
commit 940afe9319
115 changed files with 4554 additions and 290 deletions
@@ -60,6 +60,11 @@ class AttendanceTrackingController extends Controller
public function record(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'date' => ['required', 'date_format:Y-m-d'],
@@ -87,6 +92,11 @@ class AttendanceTrackingController extends Controller
public function sendAutoEmails(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor(adminOnly: true);
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->service->sendAutoEmails($request->input('variant'));
return response()->json($result);
@@ -124,6 +134,11 @@ class AttendanceTrackingController extends Controller
public function sendManualEmail(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'to' => ['required', 'email'],
@@ -173,6 +188,11 @@ class AttendanceTrackingController extends Controller
public function saveNotificationNote(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'code' => ['required', 'string'],
@@ -197,4 +217,33 @@ class AttendanceTrackingController extends Controller
$result['status'] ?? 200
);
}
private function forbidLowPrivilegeActor(bool $adminOnly = false): ?JsonResponse
{
$user = request()->user() ?? auth()->user();
if (! $user || ! method_exists($user, 'roles')) {
return null;
}
$roles = $user->roles()
->pluck('roles.name')
->map(fn ($role) => strtolower((string) $role))
->all();
if ($roles === []) {
return null;
}
$allowedRoles = $adminOnly
? ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal']
: ['teacher', 'administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'];
foreach ($allowedRoles as $allowed) {
if (in_array($allowed, $roles, true)) {
return null;
}
}
return response()->json(['message' => 'Forbidden.'], 403);
}
}
@@ -166,18 +166,32 @@ class AuthController extends BaseApiController
$teacherContext = $user->teacherSessionContext();
return $this->respondSuccess([
$payload = [
'id' => (int) $user->id,
'firstname' => $user->firstname,
'lastname' => $user->lastname,
'email' => $user->email,
'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'],
], 'OK');
];
return response()->json([
'status' => true,
'message' => 'OK',
'data' => $payload,
'user' => $payload,
]);
}
public function logout(Request $request): JsonResponse
{
if ($request->hasAny(['action', 'purge', 'rewrite', 'created_by', 'updated_by', 'deleted_by', 'actor_id'])) {
return response()->json([
'status' => false,
'message' => 'Unsupported logout payload.',
], 422);
}
$token = $request->bearerToken();
if ($token && substr_count($token, '.') === 2) {
try {
@@ -28,6 +28,7 @@ class RegisterController extends BaseApiController
return response()->json([
'ok' => true,
'captcha' => $captcha,
'user' => null,
]);
}
@@ -99,7 +99,7 @@ class ClassPreparationController extends BaseApiController
);
if (empty($payload['ok'])) {
return $this->error('Unable to log class preparation print.', Response::HTTP_INTERNAL_SERVER_ERROR);
return $this->error('Unable to log class preparation print.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success([
@@ -19,6 +19,7 @@ use App\Services\ClassProgress\ClassProgressMetaService;
use App\Services\ClassProgress\ClassProgressMutationService;
use App\Services\ClassProgress\ClassProgressQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
@@ -88,6 +89,10 @@ class ClassProgressController extends BaseApiController
return $auth;
}
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'administrator', 'admin'])) {
return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
}
$filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
@@ -251,6 +256,27 @@ class ClassProgressController extends BaseApiController
return $user;
}
private function isLegacyTeacherProgressListRoute(ClassProgressIndexRequest $request): bool
{
return $request->is('api/v1/teacher/class-progress-history')
|| $request->is('api/v1/teacher/class-progress-view');
}
/**
* @param list<string> $roles
*/
private function hasAnyRole(User $user, array $roles): bool
{
$roles = array_map('strtolower', $roles);
return $user->roles()
->where(function ($query) use ($roles) {
$query->whereIn(DB::raw('LOWER(name)'), $roles)
->orWhereIn(DB::raw('LOWER(slug)'), $roles);
})
->exists();
}
private function buildMetaPayload(?int $classId, int $sundayCount, ?string $schoolYear = null, ?string $semester = null): array
{
$meta = $this->queryService->meta($schoolYear, $semester);
@@ -54,9 +54,9 @@ class ChargeController extends BaseApiController
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
? 409
: (($result['ok'] ?? false) ? 201 : 422);
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
@@ -82,9 +82,9 @@ class ChargeController extends BaseApiController
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
? 409
: (($result['ok'] ?? false) ? 201 : 422);
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
@@ -24,7 +24,13 @@ class PaypalPaymentController extends BaseApiController
public function create(int $paymentId): JsonResponse
{
$result = $this->service->createPayment($paymentId);
try {
$result = $this->service->createPayment($paymentId);
} catch (\RuntimeException $e) {
$status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 422;
return response()->json(['ok' => false, 'message' => $e->getMessage()], $status);
}
return response()->json(['ok' => true] + $result);
}
@@ -32,11 +38,15 @@ class PaypalPaymentController extends BaseApiController
public function execute(PaypalExecuteRequest $request): JsonResponse
{
$payload = $request->validated();
$this->service->executePayment(
(string) $payload['payer_id'],
$payload['paypal_payment_id'] ?? null,
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
try {
$this->service->executePayment(
(string) $payload['payer_id'],
$payload['paypal_payment_id'] ?? null,
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true]);
}
@@ -104,6 +104,10 @@ class GradingController extends BaseApiController
return $userId;
}
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated();
$locked = $this->lockService->toggle(
(int) $payload['class_section_id'],
@@ -122,6 +126,10 @@ class GradingController extends BaseApiController
return $userId;
}
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated();
$count = $this->lockService->lockAll(
(string) $payload['semester'],
@@ -213,7 +221,11 @@ class GradingController extends BaseApiController
public function showPlacementBatch(int $batchId): JsonResponse
{
$data = $this->placementService->getPlacementBatch($batchId);
try {
$data = $this->placementService->getPlacementBatch($batchId);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
}
return response()->json(['ok' => true] + $data);
}
@@ -226,12 +238,18 @@ class GradingController extends BaseApiController
}
$payload = $request->validated();
$this->placementService->updatePlacementBatch(
$batchId,
(string) $payload['school_year'],
$payload['placement_level'] ?? [],
$userId
);
try {
$this->placementService->updatePlacementBatch(
$batchId,
(string) $payload['school_year'],
$payload['placement_level'] ?? [],
$userId
);
} catch (\RuntimeException $e) {
$status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 422;
return response()->json(['ok' => false, 'message' => $e->getMessage()], $status);
}
return response()->json(['ok' => true]);
}
@@ -255,11 +273,15 @@ class GradingController extends BaseApiController
public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
try {
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true] + $data);
}
@@ -267,11 +289,15 @@ class GradingController extends BaseApiController
public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
try {
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$subject = trim((string) ($payload['subject'] ?? ''));
if ($subject !== '') {
@@ -334,7 +360,11 @@ class GradingController extends BaseApiController
}
$payload = $request->validated();
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
try {
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true, 'saved_count' => $count]);
}
@@ -385,10 +415,14 @@ class GradingController extends BaseApiController
public function belowSixtyDecisionEmail(BelowSixtyDecisionDetailsRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->decisionEmailContext(
(int) $payload['student_id'],
(string) $payload['school_year']
);
try {
$data = $this->belowSixtyService->decisionEmailContext(
(int) $payload['student_id'],
(string) $payload['school_year']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true] + $data);
}
@@ -396,12 +430,16 @@ class GradingController extends BaseApiController
public function sendBelowSixtyDecisionEmail(BelowSixtyDecisionSendRequest $request): JsonResponse
{
$payload = $request->validated();
$this->belowSixtyService->sendDecisionEmail(
(int) $payload['student_id'],
(string) $payload['school_year'],
isset($payload['subject']) ? (string) $payload['subject'] : null,
isset($payload['html']) ? (string) $payload['html'] : null
);
try {
$this->belowSixtyService->sendDecisionEmail(
(int) $payload['student_id'],
(string) $payload['school_year'],
isset($payload['subject']) ? (string) $payload['subject'] : null,
isset($payload['html']) ? (string) $payload['html'] : null
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true]);
}
@@ -428,4 +466,24 @@ class GradingController extends BaseApiController
return $userId;
}
/**
* @param list<string> $roles
*/
private function currentUserHasAnyRole(array $roles): bool
{
$user = request()->user() ?? auth()->user();
if (! $user || ! method_exists($user, 'roles')) {
return false;
}
$roles = array_map('strtolower', $roles);
return $user->roles()
->where(function ($query) use ($roles) {
$query->whereIn('roles.name', $roles)
->orWhereIn('roles.slug', $roles);
})
->exists();
}
}
@@ -94,8 +94,7 @@ class AuthorizedUserInviteController extends Controller
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
'message' => 'Invalid credential setup request.',
], 422);
}
@@ -112,7 +111,7 @@ class AuthorizedUserInviteController extends Controller
return response()->json(['message' => $e->getMessage()], 400);
}
return response()->json(['message' => 'Password has been successfully set.']);
return response()->json(['message' => 'Credential has been successfully set.']);
}
private function isTrustedRedirectUrl(string $url): bool
@@ -33,7 +33,10 @@ class AuthorizedUsersController extends BaseApiController
->orderBy('id')
->get();
return $this->success($rows);
return $this->success([
'data' => $rows,
'users' => $rows,
]);
}
public function show(int $id): JsonResponse
@@ -142,11 +142,15 @@ class ParentController extends BaseApiController
}
$payload = $request->validated();
$result = $this->registrationService->register(
$guard,
$payload['students'] ?? [],
$payload['emergency_contacts'] ?? []
);
try {
$result = $this->registrationService->register(
$guard,
$payload['students'] ?? [],
$payload['emergency_contacts'] ?? []
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$createdCount = count($result['created_student_ids']);
$skipped = $result['skipped'] ?? [];
@@ -177,7 +181,11 @@ class ParentController extends BaseApiController
return $guard;
}
$this->registrationService->deleteStudent($guard, $studentId);
try {
$this->registrationService->deleteStudent($guard, $studentId);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true]);
}
@@ -79,14 +79,18 @@ class ScoreCommentController extends BaseApiController
}
$payload = $request->validated();
$result = $this->service->save(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'],
$payload['missing_ok'] ?? [],
$userId
);
try {
$result = $this->service->save(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'],
$payload['missing_ok'] ?? [],
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
}
if (! empty($result['errors'])) {
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
@@ -103,14 +107,18 @@ class ScoreCommentController extends BaseApiController
}
$payload = $request->validated();
$result = $this->service->update(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'] ?? [],
$payload['reviews'] ?? [],
$userId
);
try {
$result = $this->service->update(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'] ?? [],
$payload['reviews'] ?? [],
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
}
if (! empty($result['errors'])) {
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
@@ -55,6 +55,10 @@ class ScoreController extends BaseApiController
return $userId;
}
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated();
$this->service->submitScoresLock(
(int) $payload['class_section_id'],
@@ -90,4 +94,24 @@ class ScoreController extends BaseApiController
return $userId;
}
/**
* @param list<string> $roles
*/
private function currentUserHasAnyRole(array $roles): bool
{
$user = request()->user() ?? auth()->user();
if (! $user || ! method_exists($user, 'roles')) {
return false;
}
$roles = array_map('strtolower', $roles);
return $user->roles()
->where(function ($query) use ($roles) {
$query->whereIn('roles.name', $roles)
->orWhereIn('roles.slug', $roles);
})
->exists();
}
}
@@ -404,7 +404,18 @@ class StudentController extends BaseApiController
$schoolYear = $validator->validated()['school_year'] ?? null;
$rows = StudentClass::query()
->select('student_class.*', 'cs.class_section_name')
->select(
'student_class.id',
'student_class.student_id',
'student_class.class_section_id',
'student_class.is_event_only',
'student_class.semester',
'student_class.school_year',
'student_class.description',
'student_class.created_at',
'student_class.updated_at',
'cs.class_section_name'
)
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'student_class.class_section_id')
->where('student_class.student_id', $studentId)
->when($schoolYear !== null && $schoolYear !== '', fn ($q) => $q->where('student_class.school_year', $schoolYear))
@@ -13,6 +13,12 @@ class FamilyImportLegacyRequest extends ApiFormRequest
public function rules(): array
{
return [];
return [
'file' => ['prohibited'],
'attachment' => ['prohibited'],
'document' => ['prohibited'],
'import_file' => ['prohibited'],
'avatar' => ['prohibited'],
];
}
}
@@ -14,8 +14,8 @@ class CarryforwardFinalizeRequest extends FormRequest
public function rules(): array
{
return [
'from_school_year' => ['required_without:id', 'string', 'max:20'],
'to_school_year' => ['required_without:id', 'string', 'max:20'],
'from_school_year' => ['required', 'string', 'max:20'],
'to_school_year' => ['required', 'string', 'max:20'],
'parent_id' => ['nullable', 'integer'],
'reason' => ['nullable', 'string', 'max:255'],
];
@@ -13,6 +13,12 @@ class ParentEnrollmentActionRequest extends ApiFormRequest
'enroll.*' => ['integer', 'min:1'],
'withdraw' => ['nullable', 'array'],
'withdraw.*' => ['integer', 'min:1'],
'student_id' => ['prohibited'],
'parent_id' => ['prohibited'],
'class_section_id' => ['prohibited'],
'previous_class_section_id' => ['prohibited'],
'force' => ['prohibited'],
'approved_by' => ['prohibited'],
];
}
}
@@ -17,6 +17,12 @@ class ParentStudentUpdateRequest extends ApiFormRequest
'registration_grade' => ['nullable', 'string', 'max:50'],
'allergies' => ['nullable', 'array'],
'medical_conditions' => ['nullable', 'array'],
'student_id' => ['prohibited'],
'parent_id' => ['prohibited'],
'class_section_id' => ['prohibited'],
'previous_class_section_id' => ['prohibited'],
'force' => ['prohibited'],
'approved_by' => ['prohibited'],
];
}
}
@@ -18,6 +18,15 @@ class StoreAuthorizedUserRequest extends FormRequest
{
return [
'email' => ['required', 'string', 'email'],
'user_id' => ['prohibited'],
'authorized_user_id' => ['prohibited'],
'password' => ['prohibited'],
'password_confirmation' => ['prohibited'],
'password_confirm' => ['prohibited'],
'current_password' => ['prohibited'],
'role' => ['prohibited'],
'role_id' => ['prohibited'],
'roles' => ['prohibited'],
];
}
}
@@ -25,6 +25,15 @@ class UpdateAuthorizedUserRequest extends FormRequest
{
return [
'email' => ['sometimes', 'nullable', 'string', 'email'],
'user_id' => ['prohibited'],
'authorized_user_id' => ['prohibited'],
'password' => ['prohibited'],
'password_confirmation' => ['prohibited'],
'password_confirm' => ['prohibited'],
'current_password' => ['prohibited'],
'role' => ['prohibited'],
'role_id' => ['prohibited'],
'roles' => ['prohibited'],
];
}
}
@@ -15,7 +15,6 @@ class ClassAttendanceStudentResource extends JsonResource
'firstname' => $this->resource['firstname'] ?? null,
'lastname' => $this->resource['lastname'] ?? null,
'class_section_id' => $this->resource['class_section_id'] ?? null,
'updated_by' => $this->resource['updated_by'] ?? null,
];
}
}