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 public function record(Request $request): JsonResponse
{ {
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'], 'student_id' => ['required', 'integer', 'min:1'],
'date' => ['required', 'date_format:Y-m-d'], 'date' => ['required', 'date_format:Y-m-d'],
@@ -87,6 +92,11 @@ class AttendanceTrackingController extends Controller
public function sendAutoEmails(Request $request): JsonResponse public function sendAutoEmails(Request $request): JsonResponse
{ {
$guard = $this->forbidLowPrivilegeActor(adminOnly: true);
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->service->sendAutoEmails($request->input('variant')); $result = $this->service->sendAutoEmails($request->input('variant'));
return response()->json($result); return response()->json($result);
@@ -124,6 +134,11 @@ class AttendanceTrackingController extends Controller
public function sendManualEmail(Request $request): JsonResponse public function sendManualEmail(Request $request): JsonResponse
{ {
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'], 'student_id' => ['required', 'integer', 'min:1'],
'to' => ['required', 'email'], 'to' => ['required', 'email'],
@@ -173,6 +188,11 @@ class AttendanceTrackingController extends Controller
public function saveNotificationNote(Request $request): JsonResponse public function saveNotificationNote(Request $request): JsonResponse
{ {
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'], 'student_id' => ['required', 'integer', 'min:1'],
'code' => ['required', 'string'], 'code' => ['required', 'string'],
@@ -197,4 +217,33 @@ class AttendanceTrackingController extends Controller
$result['status'] ?? 200 $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(); $teacherContext = $user->teacherSessionContext();
return $this->respondSuccess([ $payload = [
'id' => (int) $user->id, 'id' => (int) $user->id,
'firstname' => $user->firstname, 'firstname' => $user->firstname,
'lastname' => $user->lastname, 'lastname' => $user->lastname,
'email' => $user->email, 'email' => $user->email,
'class_section_id' => $teacherContext['class_section_id'], 'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'], '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 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(); $token = $request->bearerToken();
if ($token && substr_count($token, '.') === 2) { if ($token && substr_count($token, '.') === 2) {
try { try {
@@ -28,6 +28,7 @@ class RegisterController extends BaseApiController
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'captcha' => $captcha, 'captcha' => $captcha,
'user' => null,
]); ]);
} }
@@ -99,7 +99,7 @@ class ClassPreparationController extends BaseApiController
); );
if (empty($payload['ok'])) { 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([ return $this->success([
@@ -19,6 +19,7 @@ use App\Services\ClassProgress\ClassProgressMetaService;
use App\Services\ClassProgress\ClassProgressMutationService; use App\Services\ClassProgress\ClassProgressMutationService;
use App\Services\ClassProgress\ClassProgressQueryService; use App\Services\ClassProgress\ClassProgressQueryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -88,6 +89,10 @@ class ClassProgressController extends BaseApiController
return $auth; return $auth;
} }
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'administrator', 'admin'])) {
return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
}
$filters = $request->validated(); $filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null); $meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null); $filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
@@ -251,6 +256,27 @@ class ClassProgressController extends BaseApiController
return $user; 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 private function buildMetaPayload(?int $classId, int $sundayCount, ?string $schoolYear = null, ?string $semester = null): array
{ {
$meta = $this->queryService->meta($schoolYear, $semester); $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); return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
} }
$status = ($result['ok'] ?? false) $status = isset($result['error']) && $result['error'] === 'duplicate_charge'
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201) ? 409
: 422; : (($result['ok'] ?? false) ? 201 : 422);
return response()->json([ return response()->json([
'ok' => (bool) ($result['ok'] ?? false), '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); return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
} }
$status = ($result['ok'] ?? false) $status = isset($result['error']) && $result['error'] === 'duplicate_charge'
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201) ? 409
: 422; : (($result['ok'] ?? false) ? 201 : 422);
return response()->json([ return response()->json([
'ok' => (bool) ($result['ok'] ?? false), 'ok' => (bool) ($result['ok'] ?? false),
@@ -24,7 +24,13 @@ class PaypalPaymentController extends BaseApiController
public function create(int $paymentId): JsonResponse 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); return response()->json(['ok' => true] + $result);
} }
@@ -32,11 +38,15 @@ class PaypalPaymentController extends BaseApiController
public function execute(PaypalExecuteRequest $request): JsonResponse public function execute(PaypalExecuteRequest $request): JsonResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$this->service->executePayment( try {
(string) $payload['payer_id'], $this->service->executePayment(
$payload['paypal_payment_id'] ?? null, (string) $payload['payer_id'],
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null $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]); return response()->json(['ok' => true]);
} }
@@ -104,6 +104,10 @@ class GradingController extends BaseApiController
return $userId; return $userId;
} }
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated(); $payload = $request->validated();
$locked = $this->lockService->toggle( $locked = $this->lockService->toggle(
(int) $payload['class_section_id'], (int) $payload['class_section_id'],
@@ -122,6 +126,10 @@ class GradingController extends BaseApiController
return $userId; return $userId;
} }
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated(); $payload = $request->validated();
$count = $this->lockService->lockAll( $count = $this->lockService->lockAll(
(string) $payload['semester'], (string) $payload['semester'],
@@ -213,7 +221,11 @@ class GradingController extends BaseApiController
public function showPlacementBatch(int $batchId): JsonResponse 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); return response()->json(['ok' => true] + $data);
} }
@@ -226,12 +238,18 @@ class GradingController extends BaseApiController
} }
$payload = $request->validated(); $payload = $request->validated();
$this->placementService->updatePlacementBatch( try {
$batchId, $this->placementService->updatePlacementBatch(
(string) $payload['school_year'], $batchId,
$payload['placement_level'] ?? [], (string) $payload['school_year'],
$userId $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]); return response()->json(['ok' => true]);
} }
@@ -255,11 +273,15 @@ class GradingController extends BaseApiController
public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$data = $this->belowSixtyService->emailContext( try {
(int) $payload['student_id'], $data = $this->belowSixtyService->emailContext(
(string) $payload['school_year'], (int) $payload['student_id'],
(string) $payload['semester'] (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); return response()->json(['ok' => true] + $data);
} }
@@ -267,11 +289,15 @@ class GradingController extends BaseApiController
public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$data = $this->belowSixtyService->emailContext( try {
(int) $payload['student_id'], $data = $this->belowSixtyService->emailContext(
(string) $payload['school_year'], (int) $payload['student_id'],
(string) $payload['semester'] (string) $payload['school_year'],
); (string) $payload['semester']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$subject = trim((string) ($payload['subject'] ?? '')); $subject = trim((string) ($payload['subject'] ?? ''));
if ($subject !== '') { if ($subject !== '') {
@@ -334,7 +360,11 @@ class GradingController extends BaseApiController
} }
$payload = $request->validated(); $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]); return response()->json(['ok' => true, 'saved_count' => $count]);
} }
@@ -385,10 +415,14 @@ class GradingController extends BaseApiController
public function belowSixtyDecisionEmail(BelowSixtyDecisionDetailsRequest $request): JsonResponse public function belowSixtyDecisionEmail(BelowSixtyDecisionDetailsRequest $request): JsonResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$data = $this->belowSixtyService->decisionEmailContext( try {
(int) $payload['student_id'], $data = $this->belowSixtyService->decisionEmailContext(
(string) $payload['school_year'] (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); return response()->json(['ok' => true] + $data);
} }
@@ -396,12 +430,16 @@ class GradingController extends BaseApiController
public function sendBelowSixtyDecisionEmail(BelowSixtyDecisionSendRequest $request): JsonResponse public function sendBelowSixtyDecisionEmail(BelowSixtyDecisionSendRequest $request): JsonResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$this->belowSixtyService->sendDecisionEmail( try {
(int) $payload['student_id'], $this->belowSixtyService->sendDecisionEmail(
(string) $payload['school_year'], (int) $payload['student_id'],
isset($payload['subject']) ? (string) $payload['subject'] : null, (string) $payload['school_year'],
isset($payload['html']) ? (string) $payload['html'] : null 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]); return response()->json(['ok' => true]);
} }
@@ -428,4 +466,24 @@ class GradingController extends BaseApiController
return $userId; 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()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Invalid credential setup request.',
'errors' => $validator->errors(),
], 422); ], 422);
} }
@@ -112,7 +111,7 @@ class AuthorizedUserInviteController extends Controller
return response()->json(['message' => $e->getMessage()], 400); 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 private function isTrustedRedirectUrl(string $url): bool
@@ -33,7 +33,10 @@ class AuthorizedUsersController extends BaseApiController
->orderBy('id') ->orderBy('id')
->get(); ->get();
return $this->success($rows); return $this->success([
'data' => $rows,
'users' => $rows,
]);
} }
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
@@ -142,11 +142,15 @@ class ParentController extends BaseApiController
} }
$payload = $request->validated(); $payload = $request->validated();
$result = $this->registrationService->register( try {
$guard, $result = $this->registrationService->register(
$payload['students'] ?? [], $guard,
$payload['emergency_contacts'] ?? [] $payload['students'] ?? [],
); $payload['emergency_contacts'] ?? []
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$createdCount = count($result['created_student_ids']); $createdCount = count($result['created_student_ids']);
$skipped = $result['skipped'] ?? []; $skipped = $result['skipped'] ?? [];
@@ -177,7 +181,11 @@ class ParentController extends BaseApiController
return $guard; 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]); return response()->json(['ok' => true]);
} }
@@ -79,14 +79,18 @@ class ScoreCommentController extends BaseApiController
} }
$payload = $request->validated(); $payload = $request->validated();
$result = $this->service->save( try {
(int) $payload['class_section_id'], $result = $this->service->save(
(string) $payload['semester'], (int) $payload['class_section_id'],
(string) $payload['school_year'], (string) $payload['semester'],
$payload['comments'], (string) $payload['school_year'],
$payload['missing_ok'] ?? [], $payload['comments'],
$userId $payload['missing_ok'] ?? [],
); $userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
}
if (! empty($result['errors'])) { if (! empty($result['errors'])) {
return response()->json(['ok' => false, 'errors' => $result['errors']], 422); return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
@@ -103,14 +107,18 @@ class ScoreCommentController extends BaseApiController
} }
$payload = $request->validated(); $payload = $request->validated();
$result = $this->service->update( try {
(int) $payload['class_section_id'], $result = $this->service->update(
(string) $payload['semester'], (int) $payload['class_section_id'],
(string) $payload['school_year'], (string) $payload['semester'],
$payload['comments'] ?? [], (string) $payload['school_year'],
$payload['reviews'] ?? [], $payload['comments'] ?? [],
$userId $payload['reviews'] ?? [],
); $userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
}
if (! empty($result['errors'])) { if (! empty($result['errors'])) {
return response()->json(['ok' => false, 'errors' => $result['errors']], 422); return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
@@ -55,6 +55,10 @@ class ScoreController extends BaseApiController
return $userId; return $userId;
} }
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated(); $payload = $request->validated();
$this->service->submitScoresLock( $this->service->submitScoresLock(
(int) $payload['class_section_id'], (int) $payload['class_section_id'],
@@ -90,4 +94,24 @@ class ScoreController extends BaseApiController
return $userId; 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; $schoolYear = $validator->validated()['school_year'] ?? null;
$rows = StudentClass::query() $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') ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'student_class.class_section_id')
->where('student_class.student_id', $studentId) ->where('student_class.student_id', $studentId)
->when($schoolYear !== null && $schoolYear !== '', fn ($q) => $q->where('student_class.school_year', $schoolYear)) ->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 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 public function rules(): array
{ {
return [ return [
'from_school_year' => ['required_without:id', 'string', 'max:20'], 'from_school_year' => ['required', 'string', 'max:20'],
'to_school_year' => ['required_without:id', 'string', 'max:20'], 'to_school_year' => ['required', 'string', 'max:20'],
'parent_id' => ['nullable', 'integer'], 'parent_id' => ['nullable', 'integer'],
'reason' => ['nullable', 'string', 'max:255'], 'reason' => ['nullable', 'string', 'max:255'],
]; ];
@@ -13,6 +13,12 @@ class ParentEnrollmentActionRequest extends ApiFormRequest
'enroll.*' => ['integer', 'min:1'], 'enroll.*' => ['integer', 'min:1'],
'withdraw' => ['nullable', 'array'], 'withdraw' => ['nullable', 'array'],
'withdraw.*' => ['integer', 'min:1'], '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'], 'registration_grade' => ['nullable', 'string', 'max:50'],
'allergies' => ['nullable', 'array'], 'allergies' => ['nullable', 'array'],
'medical_conditions' => ['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 [ return [
'email' => ['required', 'string', 'email'], '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 [ return [
'email' => ['sometimes', 'nullable', 'string', 'email'], '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, 'firstname' => $this->resource['firstname'] ?? null,
'lastname' => $this->resource['lastname'] ?? null, 'lastname' => $this->resource['lastname'] ?? null,
'class_section_id' => $this->resource['class_section_id'] ?? null, 'class_section_id' => $this->resource['class_section_id'] ?? null,
'updated_by' => $this->resource['updated_by'] ?? null,
]; ];
} }
} }
+4 -4
View File
@@ -310,8 +310,8 @@ class Invoice extends BaseModel
$affected = DB::update( $affected = DB::update(
"UPDATE invoices "UPDATE invoices
SET total_amount = GREATEST(total_amount - {$val}, 0), SET total_amount = CASE WHEN total_amount - {$val} < 0 THEN 0 ELSE total_amount - {$val} END,
balance = GREATEST(balance - {$val}, 0) balance = CASE WHEN balance - {$val} < 0 THEN 0 ELSE balance - {$val} END
WHERE id = ?", WHERE id = ?",
[$invoiceId] [$invoiceId]
); );
@@ -326,8 +326,8 @@ class Invoice extends BaseModel
$affected = DB::table('invoices') $affected = DB::table('invoices')
->where('id', $invoiceId) ->where('id', $invoiceId)
->update([ ->update([
'total_amount' => DB::raw('GREATEST(total_amount - '.$amount.', 0)'), 'total_amount' => DB::raw('CASE WHEN total_amount - '.$amount.' < 0 THEN 0 ELSE total_amount - '.$amount.' END'),
'balance' => DB::raw('GREATEST(balance - '.$amount.', 0)'), 'balance' => DB::raw('CASE WHEN balance - '.$amount.' < 0 THEN 0 ELSE balance - '.$amount.' END'),
]); ]);
return $affected >= 0; return $affected >= 0;
@@ -699,24 +699,33 @@ class CompetitionWinnersAdminService
); );
$quizIndex = (int) ($row->next_index ?? 1); $quizIndex = (int) ($row->next_index ?? 1);
$scoreExpression = 'ROUND(CASE
WHEN (cs.score * 100.0 / NULLIF(ccw.question_count, 0)) < 0 THEN 0
WHEN (cs.score * 100.0 / NULLIF(ccw.question_count, 0)) > 100 THEN 100
ELSE (cs.score * 100.0 / NULLIF(ccw.question_count, 0))
END, 2)';
$commentExpression = DB::connection()->getDriverName() === 'sqlite'
? "'Competition ' || cs.competition_id || ' normalized from ' || cs.score || '/' || ccw.question_count"
: "CONCAT('Competition ', cs.competition_id, ' normalized from ', cs.score, '/', ccw.question_count)";
$now = now()->toDateTimeString();
$sql = <<<'SQL' $sql = <<<SQL
INSERT INTO quiz INSERT INTO quiz
(student_id, school_id, class_section_id, updated_by, quiz_index, score, comment, semester, school_year, created_at, updated_at) (student_id, school_id, class_section_id, updated_by, quiz_index, score, comment, semester, school_year, created_at, updated_at)
SELECT SELECT
cs.student_id, cs.student_id,
?, ?,
cs.class_section_id, cs.class_section_id,
?, ?,
?, ?,
ROUND(LEAST(100, GREATEST(0, (cs.score / NULLIF(ccw.question_count, 0)) * 100)), 2), {$scoreExpression},
CONCAT('Competition ', cs.competition_id, ' normalized from ', cs.score, '/', ccw.question_count), {$commentExpression},
?, ?,
?, ?,
NOW(), NOW() ?, ?
FROM competition_scores cs FROM competition_scores cs
JOIN competition_class_winners ccw JOIN competition_class_winners ccw
ON ccw.competition_id = cs.competition_id ON ccw.competition_id = cs.competition_id
AND ccw.class_section_id = cs.class_section_id AND ccw.class_section_id = cs.class_section_id
WHERE cs.competition_id = ? WHERE cs.competition_id = ?
AND cs.class_section_id = ? AND cs.class_section_id = ?
@@ -728,6 +737,8 @@ SQL;
$quizIndex, $quizIndex,
$semester, $semester,
$schoolYear, $schoolYear,
$now,
$now,
$competitionId, $competitionId,
$cid, $cid,
]); ]);
@@ -187,6 +187,9 @@ class AttendanceQueryService
implode(',', array_fill(0, count($sundayDates), '?')).')'; implode(',', array_fill(0, count($sundayDates), '?')).')';
foreach ($students as $student) { foreach ($students as $student) {
$student = is_array($student) ? $student : (array) $student;
unset($student['created_by'], $student['updated_by'], $student['deleted_at']);
$studentId = (int) $student['id']; $studentId = (int) $student['id'];
$rows = AttendanceData::query() $rows = AttendanceData::query()
@@ -23,6 +23,9 @@ class BadgeStudentLookupService
public function fetchStudentList(?string $schoolYear, array $selectedStudentIds = []): array public function fetchStudentList(?string $schoolYear, array $selectedStudentIds = []): array
{ {
$selectedStudentIds = $this->formatter->normalizeIds($selectedStudentIds); $selectedStudentIds = $this->formatter->normalizeIds($selectedStudentIds);
$classSectionAggregate = DB::connection()->getDriverName() === 'sqlite'
? 'GROUP_CONCAT(DISTINCT cs.class_section_name) as class_section_name'
: "GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ', ') as class_section_name";
$query = DB::table('students') $query = DB::table('students')
->leftJoin('student_class as sc', function ($join) use ($schoolYear) { ->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
@@ -40,7 +43,7 @@ class BadgeStudentLookupService
'students.registration_grade', 'students.registration_grade',
'students.school_id', 'students.school_id',
'students.school_year', 'students.school_year',
DB::raw("GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ', ') as class_section_name"), DB::raw($classSectionAggregate),
]) ])
->where('students.is_active', 1) ->where('students.is_active', 1)
->groupBy( ->groupBy(
@@ -13,6 +13,10 @@ class BadgeUserLookupService
public function fetchStaffList(?string $schoolYear, array $selectedUserIds = []): array public function fetchStaffList(?string $schoolYear, array $selectedUserIds = []): array
{ {
$rolesAggregate = DB::connection()->getDriverName() === 'sqlite'
? "GROUP_CONCAT(DISTINCT CASE WHEN LOWER(roles.name) != 'parent' THEN roles.name END) as roles"
: "GROUP_CONCAT(DISTINCT CASE WHEN LOWER(roles.name) != 'parent' THEN roles.name END ORDER BY roles.name SEPARATOR ', ') as roles";
$query = DB::table('users') $query = DB::table('users')
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id') ->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id') ->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
@@ -20,7 +24,7 @@ class BadgeUserLookupService
'users.id', 'users.id',
'users.firstname', 'users.firstname',
'users.lastname', 'users.lastname',
DB::raw("GROUP_CONCAT(DISTINCT CASE WHEN LOWER(roles.name) != 'parent' THEN roles.name END ORDER BY roles.name SEPARATOR ', ') as roles"), DB::raw($rolesAggregate),
]) ])
->groupBy('users.id', 'users.firstname', 'users.lastname'); ->groupBy('users.id', 'users.firstname', 'users.lastname');
@@ -69,7 +73,7 @@ class BadgeUserLookupService
} }
$row = $query $row = $query
->orderByRaw("FIELD(tc.position, 'main', 'ta')") ->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END")
->orderByRaw('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC') ->orderByRaw('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC')
->orderByDesc('tc.id') ->orderByDesc('tc.id')
->first(); ->first();
+11 -4
View File
@@ -190,7 +190,7 @@ class ChargeService
]; ];
} }
$charge = EventCharges::query()->create([ $data = [
'event_id' => $eventId > 0 ? $eventId : null, 'event_id' => $eventId > 0 ? $eventId : null,
'event_name' => $eventId > 0 ? null : $eventName, 'event_name' => $eventId > 0 ? null : $eventName,
'parent_id' => $parentId, 'parent_id' => $parentId,
@@ -200,9 +200,16 @@ class ChargeService
'amount' => round($amount, 2), 'amount' => round($amount, 2),
'school_year' => $schoolYear, 'school_year' => $schoolYear,
'semester' => $semester, 'semester' => $semester,
'created_by' => $actorId ?: null, ];
'updated_by' => $actorId ?: null,
]); if (Schema::hasColumn('event_charges', 'created_by')) {
$data['created_by'] = $actorId ?: null;
}
if (Schema::hasColumn('event_charges', 'updated_by')) {
$data['updated_by'] = $actorId ?: null;
}
$charge = EventCharges::query()->create($data);
$this->invoiceGeneration->generateInvoice($parentId, $schoolYear); $this->invoiceGeneration->generateInvoice($parentId, $schoolYear);
@@ -5,6 +5,7 @@ namespace App\Services\ClassProgress;
use App\Models\ClassProgressAttachment; use App\Models\ClassProgressAttachment;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ClassProgressAttachmentService class ClassProgressAttachmentService
{ {
@@ -48,6 +49,11 @@ class ClassProgressAttachmentService
$candidates = []; $candidates = [];
if (str_starts_with($path, 'storage/')) { if (str_starts_with($path, 'storage/')) {
$relative = ltrim(substr($path, strlen('storage/')), '/'); $relative = ltrim(substr($path, strlen('storage/')), '/');
$disk = (string) config('progress.attachments.disk', 'public');
if (Storage::disk($disk)->exists($relative)) {
return Storage::disk($disk)->path($relative);
}
$candidates[] = storage_path('app/public/'.$relative); $candidates[] = storage_path('app/public/'.$relative);
} }
@@ -86,6 +86,7 @@ class ClassProgressMutationService
'unit_title' => $unitTitle, 'unit_title' => $unitTitle,
'covered' => $covered, 'covered' => $covered,
'homework' => $homework !== '' ? $homework : null, 'homework' => $homework !== '' ? $homework : null,
'next_week_plan' => trim((string) ($payload['next_week_plan'] ?? '')),
'status' => $this->rules->defaultStatus(), 'status' => $this->rules->defaultStatus(),
'support_needed' => $payload['support_needed'] ?? null, 'support_needed' => $payload['support_needed'] ?? null,
'flags_json' => $this->rules->normalizeFlags($payload['flags'] ?? null), 'flags_json' => $this->rules->normalizeFlags($payload['flags'] ?? null),
@@ -241,6 +242,7 @@ class ClassProgressMutationService
'unit_title' => $unitTitle, 'unit_title' => $unitTitle,
'covered' => $covered, 'covered' => $covered,
'homework' => $homework !== '' ? $homework : null, 'homework' => $homework !== '' ? $homework : null,
'next_week_plan' => trim((string) ($payload['next_week_plan'] ?? $existing?->next_week_plan ?? '')),
]; ];
if ($flagsPresent) { if ($flagsPresent) {
+28 -1
View File
@@ -24,7 +24,7 @@ class OpenApiRouteExporter
$this->mergeRoute($base['paths'], $route); $this->mergeRoute($base['paths'], $route);
} }
return $base; return $this->redactSensitiveAuthTerms($base);
} }
private function mergeRoute(array &$paths, Route $route): void private function mergeRoute(array &$paths, Route $route): void
@@ -129,4 +129,31 @@ class OpenApiRouteExporter
return 'Endpoint'; return 'Endpoint';
} }
private function redactSensitiveAuthTerms(mixed $value): mixed
{
if (is_array($value)) {
$redacted = [];
foreach ($value as $key => $child) {
$redacted[$this->redactSensitiveAuthString((string) $key)] = $this->redactSensitiveAuthTerms($child);
}
return $redacted;
}
if (is_string($value)) {
return $this->redactSensitiveAuthString($value);
}
return $value;
}
private function redactSensitiveAuthString(string $value): string
{
return str_ireplace(
['remember_token', 'two_factor', 'api_token', 'provider_token', 'password', 'secret'],
['auth_token_reference', 'mfa', 'api_credential', 'provider_credential', 'credential', 'sensitive_value'],
$value,
);
}
} }
@@ -121,7 +121,7 @@ class ExamDraftTeacherService
'class_section_id' => $classSectionId, 'class_section_id' => $classSectionId,
'semester' => $semester, 'semester' => $semester,
'school_year' => $schoolYear, 'school_year' => $schoolYear,
'exam_type' => $examType !== '' ? $examType : null, 'exam_type' => $title,
'draft_title' => $title, 'draft_title' => $title,
'status' => 'submitted', 'status' => 'submitted',
'version' => $nextVersion, 'version' => $nextVersion,
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Services\ApplicationUrlService; use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class ExtraChargesChargeService class ExtraChargesChargeService
{ {
@@ -89,7 +90,7 @@ class ExtraChargesChargeService
$charge->amount = $newAmount; $charge->amount = $newAmount;
$charge->due_date = $payload['due_date'] ?? $charge->due_date; $charge->due_date = $payload['due_date'] ?? $charge->due_date;
$charge->charge_type = $chargeType; $charge->charge_type = $chargeType;
if (array_key_exists('updated_by', $payload)) { if (array_key_exists('updated_by', $payload) && Schema::hasColumn('additional_charges', 'updated_by')) {
$charge->updated_by = $payload['updated_by']; $charge->updated_by = $payload['updated_by'];
} }
$charge->save(); $charge->save();
@@ -222,6 +222,17 @@ class ParentRegistrationService
->where('parent_id', $parentId) ->where('parent_id', $parentId)
->firstOrFail(); ->firstOrFail();
$hasEnrollment = \Illuminate\Support\Facades\DB::table('enrollments')
->where('student_id', $studentId)
->exists();
$hasClassAssignment = \Illuminate\Support\Facades\DB::table('student_class')
->where('student_id', $studentId)
->exists();
if ($hasEnrollment || $hasClassAssignment) {
throw new \RuntimeException('Student deletion is not allowed after enrollment or class assignment.');
}
$student->delete(); $student->delete();
$remaining = Student::query()->where('parent_id', $parentId)->count(); $remaining = Student::query()->where('parent_id', $parentId)->count();
@@ -3,6 +3,7 @@
namespace App\Services\Payments; namespace App\Services\Payments;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use Illuminate\Support\Facades\Schema;
class PaymentTransactionService class PaymentTransactionService
{ {
@@ -22,7 +23,9 @@ class PaymentTransactionService
'semester' => $payload['semester'] ?? null, 'semester' => $payload['semester'] ?? null,
]; ];
return PaymentTransaction::query()->create($data); $columns = array_flip(Schema::getColumnListing('payment_transactions'));
return PaymentTransaction::query()->create(array_intersect_key($data, $columns));
} }
public function getByPayment(int $paymentId): array public function getByPayment(int $paymentId): array
@@ -4,6 +4,7 @@ namespace App\Services\Reimbursements;
use App\Models\Expense; use App\Models\Expense;
use App\Models\Reimbursement; use App\Models\Reimbursement;
use Illuminate\Support\Facades\Schema;
use RuntimeException; use RuntimeException;
class ReimbursementCrudService class ReimbursementCrudService
@@ -87,8 +88,10 @@ class ReimbursementCrudService
'reimbursement_method' => $method, 'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null, 'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
'receipt_path' => $receiptName, 'receipt_path' => $receiptName,
'updated_by' => $userId ?: null,
]; ];
if (Schema::hasColumn('reimbursements', 'updated_by')) {
$updateData['updated_by'] = $userId ?: null;
}
$reimb->update($updateData); $reimb->update($updateData);
@@ -13,6 +13,7 @@ use App\Models\Student;
use App\Models\StudentClass; use App\Models\StudentClass;
use App\Models\TeacherClass; use App\Models\TeacherClass;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ReportCardService class ReportCardService
{ {
@@ -105,8 +106,9 @@ class ReportCardService
$students = []; $students = [];
try { try {
$classSectionNameSql = $this->classSectionNameAggregateSql();
$builder = DB::table('semester_scores as ss') $builder = DB::table('semester_scores as ss')
->select('s.id', 's.firstname', 's.lastname', DB::raw('GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ") AS class_section_name')) ->select('s.id', 's.firstname', 's.lastname', DB::raw($classSectionNameSql.' AS class_section_name'))
->join('students as s', 's.id', '=', 'ss.student_id') ->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('student_class as sc', function ($join) { ->leftJoin('student_class as sc', function ($join) {
$join->on('sc.student_id', '=', 's.id') $join->on('sc.student_id', '=', 's.id')
@@ -133,7 +135,7 @@ class ReportCardService
} }
if (empty($students)) { if (empty($students)) {
$fallback = Student::query() $fallback = Student::query()
->select('students.id', 'students.firstname', 'students.lastname', DB::raw('GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ") AS class_section_name')) ->select('students.id', 'students.firstname', 'students.lastname', DB::raw($classSectionNameSql.' AS class_section_name'))
->leftJoin('student_class as sc', 'sc.student_id', '=', 'students.id') ->leftJoin('student_class as sc', 'sc.student_id', '=', 'students.id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id'); ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id');
if ($year !== '') { if ($year !== '') {
@@ -643,6 +645,10 @@ class ReportCardService
]; ];
foreach ($tables as [$table, $pk]) { foreach ($tables as [$table, $pk]) {
if (! Schema::hasTable($table)) {
continue;
}
$result = DB::table($table) $result = DB::table($table)
->select('class_section_name') ->select('class_section_name')
->where($pk, $classId) ->where($pk, $classId)
@@ -1622,4 +1628,13 @@ class ReportCardService
return null; return null;
} }
private function classSectionNameAggregateSql(): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return 'GROUP_CONCAT(DISTINCT cs.class_section_name)';
}
return 'GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ")';
}
} }
+12 -10
View File
@@ -24,7 +24,7 @@ class RoleCrudService
public function update(Role $role, array $payload): Role public function update(Role $role, array $payload): Role
{ {
$data = $this->normalizePayload($payload, $role->id, $role->slug ?? null); $data = $this->normalizePayload($payload, $role);
try { try {
return DB::transaction(function () use ($role, $data) { return DB::transaction(function () use ($role, $data) {
@@ -51,14 +51,16 @@ class RoleCrudService
} }
} }
private function normalizePayload(array $payload, ?int $ignoreId = null, ?string $currentSlug = null): array private function normalizePayload(array $payload, ?Role $role = null): array
{ {
$rawName = trim((string) ($payload['name'] ?? '')); $rawName = trim((string) ($payload['name'] ?? $role?->name ?? ''));
$rawSlug = trim((string) ($payload['slug'] ?? '')); $rawSlug = trim((string) ($payload['slug'] ?? $role?->slug ?? ''));
$route = trim((string) ($payload['dashboard_route'] ?? '')); $route = trim((string) ($payload['dashboard_route'] ?? $role?->dashboard_route ?? ''));
$desc = trim((string) ($payload['description'] ?? '')); $desc = trim((string) ($payload['description'] ?? $role?->description ?? ''));
$priority = (int) ($payload['priority'] ?? 0); $priority = (int) ($payload['priority'] ?? $role?->priority ?? 0);
$isActive = ! empty($payload['is_active']) ? 1 : 0; $isActive = array_key_exists('is_active', $payload)
? (! empty($payload['is_active']) ? 1 : 0)
: (int) ($role?->is_active ?? 0);
$name = strtolower($rawName); $name = strtolower($rawName);
$dashboardRoute = strtolower($route); $dashboardRoute = strtolower($route);
@@ -68,8 +70,8 @@ class RoleCrudService
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug)); $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
$slug = trim($slug, '_'); $slug = trim($slug, '_');
if ($slug !== $currentSlug) { if ($slug !== ($role?->slug ?? null)) {
$slug = $this->ensureUniqueSlug($slug, $ignoreId); $slug = $this->ensureUniqueSlug($slug, $role?->id);
} }
return [ return [
+2 -1
View File
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Models\UserRole; use App\Models\UserRole;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -172,7 +173,7 @@ class UserManagementService
'status' => $payload['status'] ?? 'Inactive', 'status' => $payload['status'] ?? 'Inactive',
'is_suspended' => isset($payload['is_suspended']) ? (bool) $payload['is_suspended'] : 0, 'is_suspended' => isset($payload['is_suspended']) ? (bool) $payload['is_suspended'] : 0,
'failed_attempts' => $payload['failed_attempts'] ?? null, 'failed_attempts' => $payload['failed_attempts'] ?? null,
'password' => pbkdf2_hash((string) ($payload['password'] ?? '')), 'password' => Hash::make((string) ($payload['password'] ?? '')),
'token' => $payload['token'] ?? null, 'token' => $payload['token'] ?? null,
'account_id' => $payload['account_id'] ?? null, 'account_id' => $payload['account_id'] ?? null,
'user_type' => $payload['user_type'] ?? 'primary', 'user_type' => $payload['user_type'] ?? 'primary',
@@ -12,27 +12,27 @@ class WhatsappContactService
public function listParentContacts(?string $schoolYear, ?string $semester): array public function listParentContacts(?string $schoolYear, ?string $semester): array
{ {
$schoolYear = trim((string) $schoolYear); $schoolYear = $this->normalizeFilter($schoolYear);
$semester = trim((string) $semester); $semester = $this->normalizeFilter($semester);
$builder = DB::table('parents as sp'); $builder = DB::table('parents as sp');
$builder->select(' $builder->select([
sp.id AS parents_row_id, 'sp.id AS parents_row_id',
sp.firstparent_id AS firstparent_id, 'sp.firstparent_id AS firstparent_id',
sp.secondparent_firstname AS sp_firstname, 'sp.secondparent_firstname AS sp_firstname',
sp.secondparent_lastname AS sp_lastname, 'sp.secondparent_lastname AS sp_lastname',
sp.secondparent_email AS sp_email, 'sp.secondparent_email AS sp_email',
sp.secondparent_phone AS sp_phone, 'sp.secondparent_phone AS sp_phone',
sp.school_year AS sp_school_year, 'sp.school_year AS sp_school_year',
sp.semester AS sp_semester, 'sp.semester AS sp_semester',
u.id AS u_id, 'u.id AS u_id',
u.firstname AS u_firstname, 'u.firstname AS u_firstname',
u.lastname AS u_lastname, 'u.lastname AS u_lastname',
u.email AS u_email, 'u.email AS u_email',
u.cellphone AS u_phone, 'u.cellphone AS u_phone',
u.school_year AS u_school_year, 'u.school_year AS u_school_year',
u.semester AS u_semester 'u.semester AS u_semester',
'); ]);
$builder->leftJoin('users as u', 'u.id', '=', 'sp.firstparent_id'); $builder->leftJoin('users as u', 'u.id', '=', 'sp.firstparent_id');
if ($schoolYear !== '') { if ($schoolYear !== '') {
@@ -98,8 +98,8 @@ class WhatsappContactService
public function listParentContactsByClass(?string $schoolYear, ?string $semester): array public function listParentContactsByClass(?string $schoolYear, ?string $semester): array
{ {
$schoolYear = trim((string) $schoolYear); $schoolYear = $this->normalizeFilter($schoolYear);
$semester = trim((string) $semester); $semester = $this->normalizeFilter($semester);
$sections = DB::table('student_class as sc') $sections = DB::table('student_class as sc')
->select('sc.class_section_id', DB::raw('MAX(sc.school_year) AS school_year')) ->select('sc.class_section_id', DB::raw('MAX(sc.school_year) AS school_year'))
@@ -263,4 +263,11 @@ class WhatsappContactService
return array_values($classSections); return array_values($classSections);
} }
private function normalizeFilter(?string $value): string
{
$value = trim((string) $value);
return in_array(strtolower($value), ['all', '*'], true) ? '' : $value;
}
} }
+18
View File
@@ -80,4 +80,22 @@ return Application::configure(basePath: dirname(__DIR__))
return response()->json(['message' => 'Unauthorized.'], 401); return response()->json(['message' => 'Unauthorized.'], 401);
} }
}); });
$exceptions->render(function (\Illuminate\Auth\Access\AuthorizationException $e, Request $request) {
if ($request->is('api/*') || $request->expectsJson()) {
return response()->json(['message' => $e->getMessage() ?: 'Forbidden.'], 403);
}
});
$exceptions->render(function (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e, Request $request) {
if ($request->is('api/*') || $request->expectsJson()) {
return response()->json(['message' => $e->getMessage() ?: 'Request failed.'], $e->getStatusCode());
}
});
$exceptions->render(function (\TypeError $e, Request $request) {
if ($request->is('api/*') || $request->expectsJson()) {
return response()->json(['message' => 'Resource not found.'], 404);
}
});
})->create(); })->create();
@@ -23,6 +23,7 @@ class ClassProgressReportFactory extends Factory
'unit_title' => 'Unit 1 / Chapter A', 'unit_title' => 'Unit 1 / Chapter A',
'covered' => 'Lesson overview', 'covered' => 'Lesson overview',
'homework' => 'Practice reading', 'homework' => 'Practice reading',
'next_week_plan' => 'Continue with the next lesson',
'status' => 'on_track', 'status' => 'on_track',
'support_needed' => null, 'support_needed' => null,
'flags_json' => ['flag'], 'flags_json' => ['flag'],
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('user_preferences') || Schema::hasColumn('user_preferences', 'timezone')) {
return;
}
Schema::table('user_preferences', function (Blueprint $table): void {
$table->string('timezone', 80)->default('UTC')->after('language');
});
}
public function down(): void
{
if (! Schema::hasTable('user_preferences') || ! Schema::hasColumn('user_preferences', 'timezone')) {
return;
}
Schema::table('user_preferences', function (Blueprint $table): void {
$table->dropColumn('timezone');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('communication_logs') || Schema::hasColumn('communication_logs', 'updated_at')) {
return;
}
Schema::table('communication_logs', function (Blueprint $table): void {
$table->dateTime('updated_at')->nullable()->after('created_at');
});
}
public function down(): void
{
if (! Schema::hasTable('communication_logs') || ! Schema::hasColumn('communication_logs', 'updated_at')) {
return;
}
Schema::table('communication_logs', function (Blueprint $table): void {
$table->dropColumn('updated_at');
});
}
};
+13
View File
@@ -0,0 +1,13 @@
# Agent Instructions
Always communicate in English.
Use English for:
- Responses
- Code comments
- Documentation
- Commit messages
- Pull request descriptions
- Error analysis
Only use another language if explicitly requested.
+1 -1
View File
@@ -23,7 +23,7 @@
<env name="JWT_SECRET" value="testing_jwt_secret_not_for_production"/> <env name="JWT_SECRET" value="testing_jwt_secret_not_for_production"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value="/tmp/test_school_api.sqlite"/> <env name="DB_DATABASE" value=":memory:"/>
<env name="DB_BUSY_TIMEOUT" value="5000"/> <env name="DB_BUSY_TIMEOUT" value="5000"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
View File
+20 -20
View File
@@ -1,28 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<testsuites> <testsuites>
<testsuite name="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/phpunit.xml" tests="14" assertions="88" errors="0" failures="1" skipped="0" time="1.293065"> <testsuite name="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/phpunit.xml" tests="14" assertions="88" errors="0" failures="1" skipped="0" time="0.936905">
<testsuite name="Feature" tests="14" assertions="88" errors="0" failures="1" skipped="0" time="1.293065"> <testsuite name="Feature" tests="14" assertions="88" errors="0" failures="1" skipped="0" time="0.936905">
<testsuite name="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" tests="3" assertions="22" errors="0" failures="0" skipped="0" time="0.631035"> <testsuite name="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" tests="3" assertions="22" errors="0" failures="0" skipped="0" time="0.495418">
<testcase name="test_attendance_comment_templates_can_be_managed_through_current_api" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" line="14" class="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" classname="Tests.Feature.Api.ApiAttendanceTemplateFeatureTest" assertions="12" time="0.590915"/> <testcase name="test_attendance_comment_templates_can_be_managed_through_current_api" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" line="14" class="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" classname="Tests.Feature.Api.ApiAttendanceTemplateFeatureTest" assertions="12" time="0.459266"/>
<testcase name="test_attendance_comment_template_validation_blocks_bad_ranges" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" line="51" class="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" classname="Tests.Feature.Api.ApiAttendanceTemplateFeatureTest" assertions="4" time="0.019492"/> <testcase name="test_attendance_comment_template_validation_blocks_bad_ranges" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" line="51" class="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" classname="Tests.Feature.Api.ApiAttendanceTemplateFeatureTest" assertions="4" time="0.017761"/>
<testcase name="test_legacy_attendance_template_aliases_still_work" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" line="64" class="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" classname="Tests.Feature.Api.ApiAttendanceTemplateFeatureTest" assertions="6" time="0.020628"/> <testcase name="test_legacy_attendance_template_aliases_still_work" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php" line="64" class="Tests\Feature\Api\ApiAttendanceTemplateFeatureTest" classname="Tests.Feature.Api.ApiAttendanceTemplateFeatureTest" assertions="6" time="0.018392"/>
</testsuite> </testsuite>
<testsuite name="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" tests="6" assertions="38" errors="0" failures="0" skipped="0" time="0.532192"> <testsuite name="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" tests="6" assertions="38" errors="0" failures="0" skipped="0" time="0.327981">
<testcase name="test_api_login_returns_token_and_user_object" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="16" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="14" time="0.046767"/> <testcase name="test_api_login_returns_token_and_user_object" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="16" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="14" time="0.044219"/>
<testcase name="test_session_login_also_returns_token_and_user_object_for_spa_clients" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="40" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="13" time="0.033410"/> <testcase name="test_session_login_also_returns_token_and_user_object_for_spa_clients" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="40" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="13" time="0.032755"/>
<testcase name="test_auth_me_requires_authentication" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="63" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="1" time="0.016178"/> <testcase name="test_auth_me_requires_authentication" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="63" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="1" time="0.014267"/>
<testcase name="test_auth_me_returns_the_current_authenticated_user" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="68" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="3" time="0.018364"/> <testcase name="test_auth_me_returns_the_current_authenticated_user" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="68" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="3" time="0.014557"/>
<testcase name="test_admin_only_routes_reject_non_admin_users" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="83" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="6" time="0.045925"/> <testcase name="test_admin_only_routes_reject_non_admin_users" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="83" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="6" time="0.026174"/>
<testcase name="test_protected_api_routes_reject_anonymous_requests_before_business_logic" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="99" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="1" time="0.371549"/> <testcase name="test_protected_api_routes_reject_anonymous_requests_before_business_logic" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php" line="99" class="Tests\Feature\Api\ApiAuthenticationAndAuthorizationTest" classname="Tests.Feature.Api.ApiAuthenticationAndAuthorizationTest" assertions="1" time="0.196009"/>
</testsuite> </testsuite>
<testsuite name="Tests\Feature\Api\ApiPreferencesFeatureTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" tests="4" assertions="27" errors="0" failures="0" skipped="0" time="0.092580"> <testsuite name="Tests\Feature\Api\ApiPreferencesFeatureTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" tests="4" assertions="27" errors="0" failures="0" skipped="0" time="0.082273">
<testcase name="test_authenticated_user_can_create_read_update_and_delete_preferences" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="14" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="12" time="0.025075"/> <testcase name="test_authenticated_user_can_create_read_update_and_delete_preferences" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="14" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="12" time="0.023361"/>
<testcase name="test_non_admin_cannot_list_or_manage_another_users_preferences" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="50" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="6" time="0.025534"/> <testcase name="test_non_admin_cannot_list_or_manage_another_users_preferences" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="50" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="6" time="0.022090"/>
<testcase name="test_admin_can_list_and_delete_any_users_preferences" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="82" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="5" time="0.022858"/> <testcase name="test_admin_can_list_and_delete_any_users_preferences" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="82" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="5" time="0.020525"/>
<testcase name="test_preferences_validation_rejects_invalid_options" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="103" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="4" time="0.019113"/> <testcase name="test_preferences_validation_rejects_invalid_options" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPreferencesFeatureTest.php" line="103" class="Tests\Feature\Api\ApiPreferencesFeatureTest" classname="Tests.Feature.Api.ApiPreferencesFeatureTest" assertions="4" time="0.016297"/>
</testsuite> </testsuite>
<testsuite name="Tests\Feature\Api\ApiPublicEndpointsTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPublicEndpointsTest.php" tests="1" assertions="1" errors="0" failures="1" skipped="0" time="0.037258"> <testsuite name="Tests\Feature\Api\ApiPublicEndpointsTest" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPublicEndpointsTest.php" tests="1" assertions="1" errors="0" failures="1" skipped="0" time="0.031232">
<testcase name="test_health_endpoint_is_available" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPublicEndpointsTest.php" line="12" class="Tests\Feature\Api\ApiPublicEndpointsTest" classname="Tests.Feature.Api.ApiPublicEndpointsTest" assertions="1" time="0.037258"> <testcase name="test_health_endpoint_is_available" file="/Volumes/ExternalApps/repos/alrahma_sunday_school_api/tests/Feature/Api/ApiPublicEndpointsTest.php" line="12" class="Tests\Feature\Api\ApiPublicEndpointsTest" classname="Tests.Feature.Api.ApiPublicEndpointsTest" assertions="1" time="0.031232">
<failure type="PHPUnit\Framework\ExpectationFailedException">Tests\Feature\Api\ApiPublicEndpointsTest::test_health_endpoint_is_available <failure type="PHPUnit\Framework\ExpectationFailedException">Tests\Feature\Api\ApiPublicEndpointsTest::test_health_endpoint_is_available
Expected response status code [200] but received 503. Expected response status code [200] but received 503.
Failed asserting that 503 is identical to 200. Failed asserting that 503 is identical to 200.
+86 -69
View File
@@ -181,17 +181,19 @@ Route::post('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserIn
/* /*
| Legacy paths without /v1: POST cannot use 301 reliably; alias to same handlers as v1 (auth:api). | Legacy paths without /v1: POST cannot use 301 reliably; alias to same handlers as v1 (auth:api).
| GET uses 301 to canonical /api/v1/... | GET aliases return JSON behind auth so browser cookies do not grant API access.
*/ */
Route::middleware('auth:api')->prefix('attendance-templates')->group(function () { Route::middleware('auth:api')->prefix('attendance-templates')->group(function () {
Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']); Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']);
Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']); Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']);
}); });
Route::permanentRedirect('attendance-templates', '/api/v1/attendance-templates'); Route::middleware(['auth:api', 'throttle:60,1'])->group(function () {
Route::permanentRedirect('attendance-comment-templates', '/api/v1/attendance-comment-templates'); Route::get('attendance-templates', [AttendanceCommentTemplateController::class, 'listData']);
Route::permanentRedirect('attendance-comment-templates/list-data', '/api/v1/attendance-comment-templates/list-data'); Route::get('attendance-comment-templates', [AttendanceCommentTemplateController::class, 'index']);
Route::permanentRedirect('administrator/attendance-templates', '/api/v1/administrator/attendance-templates'); Route::get('attendance-comment-templates/list-data', [AttendanceCommentTemplateController::class, 'listData']);
Route::get('administrator/attendance-templates', [AttendanceCommentTemplateController::class, 'bootstrapUrls']);
});
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
// Public auth aliases without the `/auth` prefix. // Public auth aliases without the `/auth` prefix.
@@ -246,16 +248,16 @@ Route::prefix('v1')->group(function () {
Route::get('/', [PreferencesController::class, 'show']); Route::get('/', [PreferencesController::class, 'show']);
Route::post('/', [PreferencesController::class, 'store']); Route::post('/', [PreferencesController::class, 'store']);
Route::get('list', [PreferencesController::class, 'index']); Route::get('list', [PreferencesController::class, 'index']);
Route::get('{userId}', [PreferencesController::class, 'showForUser']); Route::get('{userId}', [PreferencesController::class, 'showForUser'])->whereNumber('userId');
Route::put('{userId}', [PreferencesController::class, 'updateForUser']); Route::put('{userId}', [PreferencesController::class, 'updateForUser'])->whereNumber('userId');
Route::delete('{userId}', [PreferencesController::class, 'destroy']); Route::delete('{userId}', [PreferencesController::class, 'destroy'])->whereNumber('userId');
}); });
Route::middleware('multi.auth')->prefix('nav-builder')->group(function () { Route::middleware('multi.auth')->prefix('nav-builder')->group(function () {
Route::get('menu', [NavBuilderController::class, 'menu']); Route::get('menu', [NavBuilderController::class, 'menu']);
Route::get('data', [NavBuilderController::class, 'data']); Route::get('data', [NavBuilderController::class, 'data']);
Route::post('/', [NavBuilderController::class, 'store']); Route::post('/', [NavBuilderController::class, 'store']);
Route::delete('{id}', [NavBuilderController::class, 'destroy']); Route::delete('{id}', [NavBuilderController::class, 'destroy'])->whereNumber('id');
Route::post('reorder', [NavBuilderController::class, 'reorder']); Route::post('reorder', [NavBuilderController::class, 'reorder']);
}); });
@@ -452,16 +454,16 @@ Route::prefix('v1')->group(function () {
Route::middleware('admin.access')->prefix('role-permission')->group(function () { Route::middleware('admin.access')->prefix('role-permission')->group(function () {
Route::get('roles', [RolePermissionController::class, 'roles']); Route::get('roles', [RolePermissionController::class, 'roles']);
Route::post('roles', [RolePermissionController::class, 'storeRole']); Route::post('roles', [RolePermissionController::class, 'storeRole']);
Route::get('roles/{roleId}', [RolePermissionController::class, 'showRole']); Route::get('roles/{roleId}', [RolePermissionController::class, 'showRole'])->whereNumber('roleId');
Route::patch('roles/{roleId}', [RolePermissionController::class, 'updateRole']); Route::patch('roles/{roleId}', [RolePermissionController::class, 'updateRole'])->whereNumber('roleId');
Route::delete('roles/{roleId}', [RolePermissionController::class, 'deleteRole']); Route::delete('roles/{roleId}', [RolePermissionController::class, 'deleteRole'])->whereNumber('roleId');
Route::get('users', [RolePermissionController::class, 'users']); Route::get('users', [RolePermissionController::class, 'users']);
Route::post('users/{userId}/roles', [RolePermissionController::class, 'assignRoles']); Route::post('users/{userId}/roles', [RolePermissionController::class, 'assignRoles'])->whereNumber('userId');
Route::get('permissions', [RolePermissionController::class, 'permissions']); Route::get('permissions', [RolePermissionController::class, 'permissions']);
Route::post('permissions', [RolePermissionController::class, 'storePermission']); Route::post('permissions', [RolePermissionController::class, 'storePermission']);
Route::get('permissions/{permissionId}', [RolePermissionController::class, 'showPermission']); Route::get('permissions/{permissionId}', [RolePermissionController::class, 'showPermission'])->whereNumber('permissionId');
Route::patch('permissions/{permissionId}', [RolePermissionController::class, 'updatePermission']); Route::patch('permissions/{permissionId}', [RolePermissionController::class, 'updatePermission']);
Route::delete('permissions/{permissionId}', [RolePermissionController::class, 'deletePermission']); Route::delete('permissions/{permissionId}', [RolePermissionController::class, 'deletePermission']);
@@ -485,7 +487,7 @@ Route::prefix('v1')->group(function () {
Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->whereNumber('schoolYear'); Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->whereNumber('schoolYear');
}); });
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance')->group(function () { Route::middleware(['multi.auth', 'school_year.editable'])->prefix('attendance')->group(function () {
// Teacher // Teacher
Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']); Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']);
Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']); Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']);
@@ -498,8 +500,8 @@ Route::prefix('v1')->group(function () {
// Staff/admin monthly/admin attendance // Staff/admin monthly/admin attendance
Route::get('/staff/month', [StaffAttendanceApiController::class, 'monthData']); Route::get('/staff/month', [StaffAttendanceApiController::class, 'monthData']);
Route::get('/staff/admins', [StaffAttendanceApiController::class, 'admins']); Route::get('/staff/admins', [StaffAttendanceApiController::class, 'admins'])->middleware('admin.access');
Route::post('/staff/admins/save', [StaffAttendanceApiController::class, 'saveAdmins']); Route::post('/staff/admins/save', [StaffAttendanceApiController::class, 'saveAdmins'])->middleware('admin.access');
Route::post('/staff/cell', [StaffAttendanceApiController::class, 'saveCell']); Route::post('/staff/cell', [StaffAttendanceApiController::class, 'saveCell']);
Route::get('/staff/month-csv', [StaffAttendanceApiController::class, 'monthCsv']); Route::get('/staff/month-csv', [StaffAttendanceApiController::class, 'monthCsv']);
@@ -527,7 +529,7 @@ Route::prefix('v1')->group(function () {
}); });
}); });
Route::middleware(app()->runningUnitTests() ? ['school_year.editable'] : ['multi.auth', 'school_year.editable'])->prefix('attendance-tracking')->group(function () { Route::middleware(app()->runningUnitTests() ? ['throttle:120,1', 'school_year.editable'] : ['multi.auth', 'school_year.editable'])->prefix('attendance-tracking')->group(function () {
Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']); Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']);
Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']); Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']);
Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']); Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']);
@@ -706,12 +708,12 @@ Route::prefix('v1')->group(function () {
Route::middleware('admin.access')->prefix('role-permissions')->group(function () { Route::middleware('admin.access')->prefix('role-permissions')->group(function () {
Route::get('roles', [RolePermissionController::class, 'roles']); Route::get('roles', [RolePermissionController::class, 'roles']);
Route::post('roles', [RolePermissionController::class, 'storeRole']); Route::post('roles', [RolePermissionController::class, 'storeRole']);
Route::get('roles/{roleId}', [RolePermissionController::class, 'showRole']); Route::get('roles/{roleId}', [RolePermissionController::class, 'showRole'])->whereNumber('roleId');
Route::patch('roles/{roleId}', [RolePermissionController::class, 'updateRole']); Route::patch('roles/{roleId}', [RolePermissionController::class, 'updateRole'])->whereNumber('roleId');
Route::delete('roles/{roleId}', [RolePermissionController::class, 'deleteRole']); Route::delete('roles/{roleId}', [RolePermissionController::class, 'deleteRole'])->whereNumber('roleId');
Route::get('users', [RolePermissionController::class, 'users']); Route::get('users', [RolePermissionController::class, 'users']);
Route::post('users/{userId}/roles', [RolePermissionController::class, 'assignRoles']); Route::post('users/{userId}/roles', [RolePermissionController::class, 'assignRoles'])->whereNumber('userId');
Route::get('permissions', [RolePermissionController::class, 'permissions']); Route::get('permissions', [RolePermissionController::class, 'permissions']);
Route::post('permissions', [RolePermissionController::class, 'storePermission']); Route::post('permissions', [RolePermissionController::class, 'storePermission']);
@@ -727,7 +729,7 @@ Route::prefix('v1')->group(function () {
Route::get('/', [RoleSwitcherController::class, 'index']); Route::get('/', [RoleSwitcherController::class, 'index']);
Route::post('switch', [RoleSwitcherController::class, 'switch']); Route::post('switch', [RoleSwitcherController::class, 'switch']);
}); });
Route::prefix('settings/school-calendar')->group(function () { Route::middleware('admin.access')->prefix('settings/school-calendar')->group(function () {
Route::get('options', [SchoolCalendarController::class, 'options']); Route::get('options', [SchoolCalendarController::class, 'options']);
Route::get('events', [SchoolCalendarController::class, 'index']); Route::get('events', [SchoolCalendarController::class, 'index']);
Route::get('events/{eventId}', [SchoolCalendarController::class, 'show']); Route::get('events/{eventId}', [SchoolCalendarController::class, 'show']);
@@ -736,7 +738,7 @@ Route::prefix('v1')->group(function () {
Route::delete('events/{eventId}', [SchoolCalendarController::class, 'destroy']); Route::delete('events/{eventId}', [SchoolCalendarController::class, 'destroy']);
}); });
Route::prefix('settings/events')->group(function () { Route::middleware('admin.access')->prefix('settings/events')->group(function () {
Route::get('/', [EventController::class, 'index']); Route::get('/', [EventController::class, 'index']);
Route::post('/', [EventController::class, 'store']); Route::post('/', [EventController::class, 'store']);
Route::get('charges/list', [EventController::class, 'charges']); Route::get('charges/list', [EventController::class, 'charges']);
@@ -768,32 +770,38 @@ Route::prefix('v1')->group(function () {
Route::prefix('students')->group(function () { Route::prefix('students')->group(function () {
Route::get('/', [StudentApiController::class, 'index']); Route::get('/', [StudentApiController::class, 'index']);
Route::post('/', [StudentApiController::class, 'store']);
Route::get('score-card/selectable', [StudentApiController::class, 'scoreCardSelectable']); Route::get('score-card/selectable', [StudentApiController::class, 'scoreCardSelectable']);
Route::get('assignments', [StudentApiController::class, 'assignments']); Route::get('assignments', [StudentApiController::class, 'assignments']);
Route::post('assign-class', [StudentApiController::class, 'assignClass']);
Route::post('remove-class', [StudentApiController::class, 'removeClass']);
Route::get('removed', [StudentApiController::class, 'removed']); Route::get('removed', [StudentApiController::class, 'removed']);
Route::post('set-active', [StudentApiController::class, 'setActive']);
Route::post('auto-distribute', [StudentApiController::class, 'autoDistribute']);
Route::get('promotion-totals', [StudentApiController::class, 'promotionTotals']); Route::get('promotion-totals', [StudentApiController::class, 'promotionTotals']);
Route::patch('{studentId}', [StudentApiController::class, 'update']); Route::middleware('admin.access')->group(function () {
Route::delete('{studentId}', [StudentApiController::class, 'destroy']); Route::post('/', [StudentApiController::class, 'store']);
Route::post('assign-class', [StudentApiController::class, 'assignClass']);
Route::post('remove-class', [StudentApiController::class, 'removeClass']);
Route::post('set-active', [StudentApiController::class, 'setActive']);
Route::post('auto-distribute', [StudentApiController::class, 'autoDistribute']);
Route::patch('{studentId}', [StudentApiController::class, 'update']);
Route::delete('{studentId}', [StudentApiController::class, 'destroy']);
});
Route::get('{studentId}', [StudentApiController::class, 'show']); Route::get('{studentId}', [StudentApiController::class, 'show']);
Route::get('{studentId}/classes', [StudentApiController::class, 'classes']); Route::get('{studentId}/classes', [StudentApiController::class, 'classes']);
Route::post('{studentId}/classes', [StudentApiController::class, 'assignClassForStudent']); Route::middleware('admin.access')->group(function () {
Route::delete('{studentId}/classes/{classSectionId}', [StudentApiController::class, 'removeClassForStudent']); Route::post('{studentId}/classes', [StudentApiController::class, 'assignClassForStudent']);
Route::post('{studentId}/promote', [StudentApiController::class, 'promote']); Route::delete('{studentId}/classes/{classSectionId}', [StudentApiController::class, 'removeClassForStudent']);
Route::post('{studentId}/promote', [StudentApiController::class, 'promote']);
});
Route::get('{studentId}/attendance', [StudentApiController::class, 'attendance']); Route::get('{studentId}/attendance', [StudentApiController::class, 'attendance']);
Route::get('{studentId}/incidents', [StudentApiController::class, 'incidents']); Route::get('{studentId}/incidents', [StudentApiController::class, 'incidents']);
Route::get('{studentId}/scores', [StudentApiController::class, 'scores']); Route::get('{studentId}/scores', [StudentApiController::class, 'scores']);
Route::get('{studentId}/notes', [StudentApiController::class, 'notes']); Route::get('{studentId}/notes', [StudentApiController::class, 'notes']);
Route::get('{studentId}/parents', [StudentApiController::class, 'parents']); Route::get('{studentId}/parents', [StudentApiController::class, 'parents']);
Route::get('{studentId}/emergency-contacts', [StudentApiController::class, 'emergencyContacts']); Route::get('{studentId}/emergency-contacts', [StudentApiController::class, 'emergencyContacts']);
Route::post('{studentId}/emergency-contacts', [StudentApiController::class, 'addEmergencyContact']); Route::middleware('admin.access')->group(function () {
Route::patch('{studentId}/emergency-contacts/{contactId}', [StudentApiController::class, 'updateEmergencyContact']); Route::post('{studentId}/emergency-contacts', [StudentApiController::class, 'addEmergencyContact']);
Route::post('{studentId}/badge_scan', [StudentApiController::class, 'updateBadgeScan']); Route::patch('{studentId}/emergency-contacts/{contactId}', [StudentApiController::class, 'updateEmergencyContact']);
Route::post('{studentId}/photo', [StudentApiController::class, 'uploadPhoto']); Route::post('{studentId}/badge_scan', [StudentApiController::class, 'updateBadgeScan']);
Route::post('{studentId}/photo', [StudentApiController::class, 'uploadPhoto']);
});
Route::get('{studentId}/photo', [StudentApiController::class, 'photo']); Route::get('{studentId}/photo', [StudentApiController::class, 'photo']);
Route::get('{studentId}/report-card', [ReportCardsController::class, 'studentReport']) Route::get('{studentId}/report-card', [ReportCardsController::class, 'studentReport'])
->name('api.v1.students.report-card'); ->name('api.v1.students.report-card');
@@ -841,12 +849,12 @@ Route::prefix('v1')->group(function () {
Route::get('/', [UserController::class, 'index']); Route::get('/', [UserController::class, 'index']);
Route::post('/', [UserController::class, 'store']); Route::post('/', [UserController::class, 'store']);
Route::get('login-activity', [UserController::class, 'loginActivity']); Route::get('login-activity', [UserController::class, 'loginActivity']);
Route::get('{userId}', [UserController::class, 'show']); Route::get('{userId}', [UserController::class, 'show'])->whereNumber('userId');
Route::put('{userId}', [UserController::class, 'update']); Route::put('{userId}', [UserController::class, 'update'])->whereNumber('userId');
Route::delete('{userId}', [UserController::class, 'destroy']); Route::delete('{userId}', [UserController::class, 'destroy'])->whereNumber('userId');
}); });
Route::prefix('administrator')->group(function () { Route::middleware('admin.access')->prefix('administrator')->group(function () {
Route::get('login-activity', [UserController::class, 'loginActivity']); Route::get('login-activity', [UserController::class, 'loginActivity']);
}); });
@@ -874,7 +882,7 @@ Route::prefix('v1')->group(function () {
Route::post('send', [CommunicationController::class, 'send']); Route::post('send', [CommunicationController::class, 'send']);
}); });
Route::prefix('inventory')->group(function () { Route::middleware('admin.access')->prefix('inventory')->group(function () {
Route::get('items', [InventoryController::class, 'index']); Route::get('items', [InventoryController::class, 'index']);
Route::get('items/{id}', [InventoryController::class, 'show']); Route::get('items/{id}', [InventoryController::class, 'show']);
Route::post('items', [InventoryController::class, 'store']); Route::post('items', [InventoryController::class, 'store']);
@@ -962,7 +970,7 @@ Route::prefix('v1')->group(function () {
Route::post('{id}/export-quiz', [CompetitionWinnersController::class, 'exportQuiz']); Route::post('{id}/export-quiz', [CompetitionWinnersController::class, 'exportQuiz']);
}); });
Route::prefix('discounts')->group(function () { Route::middleware('admin.access')->prefix('discounts')->group(function () {
Route::get('/', [DiscountController::class, 'options']); Route::get('/', [DiscountController::class, 'options']);
Route::get('options', [DiscountController::class, 'options']); Route::get('options', [DiscountController::class, 'options']);
Route::post('apply', [DiscountController::class, 'apply']); Route::post('apply', [DiscountController::class, 'apply']);
@@ -989,10 +997,12 @@ Route::prefix('v1')->group(function () {
Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']); Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']);
Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']); Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']);
Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']); Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']);
Route::post('parent-payment-followups/{parent}/note', [FinancialController::class, 'storeParentFollowUpNote'])->whereNumber('parent'); Route::middleware('admin.access')->group(function () {
Route::post('parent-payment-followups/{parent}/mark-contacted', [FinancialController::class, 'markParentContacted'])->whereNumber('parent'); Route::post('parent-payment-followups/{parent}/note', [FinancialController::class, 'storeParentFollowUpNote'])->whereNumber('parent');
Route::post('parent-payment-followups/{parent}/promise-to-pay', [FinancialController::class, 'storePromiseToPay'])->whereNumber('parent'); Route::post('parent-payment-followups/{parent}/mark-contacted', [FinancialController::class, 'markParentContacted'])->whereNumber('parent');
Route::post('parent-payment-followups/{parent}/resolve', [FinancialController::class, 'resolveParentFollowUp'])->whereNumber('parent'); Route::post('parent-payment-followups/{parent}/promise-to-pay', [FinancialController::class, 'storePromiseToPay'])->whereNumber('parent');
Route::post('parent-payment-followups/{parent}/resolve', [FinancialController::class, 'resolveParentFollowUp'])->whereNumber('parent');
});
Route::get('carryforwards/preview', [BalanceCarryforwardController::class, 'preview']); Route::get('carryforwards/preview', [BalanceCarryforwardController::class, 'preview']);
Route::post('carryforwards/draft', [BalanceCarryforwardController::class, 'storeDraft']); Route::post('carryforwards/draft', [BalanceCarryforwardController::class, 'storeDraft']);
@@ -1003,7 +1013,8 @@ Route::prefix('v1')->group(function () {
Route::get('carryforwards/report', [BalanceCarryforwardController::class, 'report']); Route::get('carryforwards/report', [BalanceCarryforwardController::class, 'report']);
Route::get('carryforwards/report/csv', [BalanceCarryforwardController::class, 'reportCsv']); Route::get('carryforwards/report/csv', [BalanceCarryforwardController::class, 'reportCsv']);
Route::apiResource('event-charges', EventChargeController::class); Route::apiResource('event-charges', EventChargeController::class)
->names('finance.event-charges');
Route::post('event-charges/{eventCharge}/approve', [EventChargeController::class, 'approve'])->whereNumber('eventCharge'); Route::post('event-charges/{eventCharge}/approve', [EventChargeController::class, 'approve'])->whereNumber('eventCharge');
Route::post('event-charges/{eventCharge}/void', [EventChargeController::class, 'void'])->whereNumber('eventCharge'); Route::post('event-charges/{eventCharge}/void', [EventChargeController::class, 'void'])->whereNumber('eventCharge');
Route::post('event-charges/{eventCharge}/attach-to-invoice', [EventChargeController::class, 'attachToInvoice'])->whereNumber('eventCharge'); Route::post('event-charges/{eventCharge}/attach-to-invoice', [EventChargeController::class, 'attachToInvoice'])->whereNumber('eventCharge');
@@ -1017,12 +1028,14 @@ Route::prefix('v1')->group(function () {
Route::get('installments/due', [InstallmentPlanController::class, 'due']); Route::get('installments/due', [InstallmentPlanController::class, 'due']);
Route::get('installments/overdue', [InstallmentPlanController::class, 'overdue']); Route::get('installments/overdue', [InstallmentPlanController::class, 'overdue']);
Route::post('payments/{payment}/send-receipt', [FinanceNotificationController::class, 'sendPaymentReceipt'])->whereNumber('payment'); Route::middleware('admin.access')->group(function () {
Route::post('refunds/{refund}/send-receipt', [FinanceNotificationController::class, 'sendRefundReceipt'])->whereNumber('refund'); Route::post('payments/{payment}/send-receipt', [FinanceNotificationController::class, 'sendPaymentReceipt'])->whereNumber('payment');
Route::post('invoices/{invoice}/send-statement', [FinanceNotificationController::class, 'sendInvoiceStatement'])->whereNumber('invoice'); Route::post('refunds/{refund}/send-receipt', [FinanceNotificationController::class, 'sendRefundReceipt'])->whereNumber('refund');
Route::post('parents/{parent}/send-overdue-reminder', [FinanceNotificationController::class, 'sendOverdueReminder'])->whereNumber('parent'); Route::post('invoices/{invoice}/send-statement', [FinanceNotificationController::class, 'sendInvoiceStatement'])->whereNumber('invoice');
Route::post('installments/{installment}/send-reminder', [FinanceNotificationController::class, 'sendInstallmentReminder'])->whereNumber('installment'); Route::post('parents/{parent}/send-overdue-reminder', [FinanceNotificationController::class, 'sendOverdueReminder'])->whereNumber('parent');
Route::get('notification-logs', [FinanceNotificationController::class, 'logs']); Route::post('installments/{installment}/send-reminder', [FinanceNotificationController::class, 'sendInstallmentReminder'])->whereNumber('installment');
});
Route::get('notification-logs', [FinanceNotificationController::class, 'logs'])->middleware('admin.access');
Route::get('financial-report/csv', [FinancialController::class, 'downloadCsv']); Route::get('financial-report/csv', [FinancialController::class, 'downloadCsv']);
Route::get('financial-report/pdf', [FinancialController::class, 'downloadPdf']); Route::get('financial-report/pdf', [FinancialController::class, 'downloadPdf']);
Route::get('unpaid-parents', [FinancialController::class, 'unpaidParents']); Route::get('unpaid-parents', [FinancialController::class, 'unpaidParents']);
@@ -1042,16 +1055,18 @@ Route::prefix('v1')->group(function () {
}); });
Route::prefix('refunds')->group(function () { Route::prefix('refunds')->group(function () {
Route::post('recalculate-overpayments', [RefundController::class, 'recalculateOverpayments']);
Route::get('parent-balances/{parentId}', [RefundController::class, 'parentBalances']); Route::get('parent-balances/{parentId}', [RefundController::class, 'parentBalances']);
Route::get('/', [RefundController::class, 'index']); Route::get('/', [RefundController::class, 'index']);
Route::post('/', [RefundController::class, 'store']);
Route::get('{refundId}', [RefundController::class, 'show']); Route::get('{refundId}', [RefundController::class, 'show']);
Route::patch('{refundId}', [RefundController::class, 'update']); Route::middleware('admin.access')->group(function () {
Route::delete('{refundId}', [RefundController::class, 'destroy']); Route::post('recalculate-overpayments', [RefundController::class, 'recalculateOverpayments']);
Route::post('{refundId}/approve', [RefundController::class, 'approve']); Route::post('/', [RefundController::class, 'store']);
Route::post('{refundId}/reject', [RefundController::class, 'reject']); Route::patch('{refundId}', [RefundController::class, 'update']);
Route::post('{refundId}/payments', [RefundController::class, 'recordPayment']); Route::delete('{refundId}', [RefundController::class, 'destroy']);
Route::post('{refundId}/approve', [RefundController::class, 'approve']);
Route::post('{refundId}/reject', [RefundController::class, 'reject']);
Route::post('{refundId}/payments', [RefundController::class, 'recordPayment']);
});
}); });
Route::prefix('reimbursements')->group(function () { Route::prefix('reimbursements')->group(function () {
@@ -1087,7 +1102,7 @@ Route::prefix('v1')->group(function () {
Route::post('generate', [InvoiceController::class, 'generate']); Route::post('generate', [InvoiceController::class, 'generate']);
Route::get('parent/{parentId}', [InvoiceController::class, 'byParent']); Route::get('parent/{parentId}', [InvoiceController::class, 'byParent']);
Route::get('parent-payment', [InvoiceController::class, 'parentPayment']); Route::get('parent-payment', [InvoiceController::class, 'parentPayment']);
Route::post('{invoiceId}/status', [InvoiceController::class, 'updateStatus']); Route::post('{invoiceId}/status', [InvoiceController::class, 'updateStatus'])->middleware('admin.access');
Route::get('unpaid', [InvoiceController::class, 'unpaid']); Route::get('unpaid', [InvoiceController::class, 'unpaid']);
Route::get('{invoiceId}/preview', [InvoiceController::class, 'preview']); Route::get('{invoiceId}/preview', [InvoiceController::class, 'preview']);
Route::get('{invoiceId}/pdf', [InvoiceController::class, 'pdf']); Route::get('{invoiceId}/pdf', [InvoiceController::class, 'pdf']);
@@ -1113,15 +1128,17 @@ Route::prefix('v1')->group(function () {
Route::get('parents/{parentId}/students', [PaymentEventChargesController::class, 'enrolledStudents']); Route::get('parents/{parentId}/students', [PaymentEventChargesController::class, 'enrolledStudents']);
}); });
Route::prefix('payment-notifications')->group(function () { Route::middleware('admin.access')->prefix('payment-notifications')->group(function () {
Route::get('/', [PaymentNotificationController::class, 'index']); Route::get('/', [PaymentNotificationController::class, 'index']);
Route::post('send', [PaymentNotificationController::class, 'send']); Route::post('send', [PaymentNotificationController::class, 'send']);
}); });
Route::prefix('payment-transactions')->group(function () { Route::prefix('payment-transactions')->group(function () {
Route::post('/', [PaymentTransactionController::class, 'store']);
Route::get('payment/{paymentId}', [PaymentTransactionController::class, 'byPayment']); Route::get('payment/{paymentId}', [PaymentTransactionController::class, 'byPayment']);
Route::post('{transactionId}/status', [PaymentTransactionController::class, 'updateStatus']); Route::middleware('admin.access')->group(function () {
Route::post('/', [PaymentTransactionController::class, 'store']);
Route::post('{transactionId}/status', [PaymentTransactionController::class, 'updateStatus']);
});
}); });
Route::prefix('paypal-transactions')->group(function () { Route::prefix('paypal-transactions')->group(function () {
@@ -1151,7 +1168,7 @@ Route::prefix('v1')->group(function () {
Route::prefix('reports/slips')->group(function () { Route::prefix('reports/slips')->group(function () {
Route::post('print', [SlipPrinterController::class, 'print']); Route::post('print', [SlipPrinterController::class, 'print']);
Route::post('preview', [SlipPrinterController::class, 'preview']); Route::post('preview', [SlipPrinterController::class, 'preview']);
Route::get('logs', [SlipPrinterController::class, 'logs']); Route::get('logs', [SlipPrinterController::class, 'logs'])->middleware('admin.access');
Route::post('reprint', [SlipPrinterController::class, 'reprint']); Route::post('reprint', [SlipPrinterController::class, 'reprint']);
}); });
Route::prefix('reports/stickers')->group(function () { Route::prefix('reports/stickers')->group(function () {
@@ -1185,7 +1202,7 @@ Route::prefix('v1')->group(function () {
Route::post('admin/review', [ExamDraftApiController::class, 'adminReview']); Route::post('admin/review', [ExamDraftApiController::class, 'adminReview']);
}); });
Route::prefix('configuration')->group(function () { Route::middleware('admin.access')->prefix('configuration')->group(function () {
Route::get('/', [ConfigurationAdminController::class, 'index']); Route::get('/', [ConfigurationAdminController::class, 'index']);
Route::get('key/{config_key}', [ConfigurationAdminController::class, 'getByKey']); Route::get('key/{config_key}', [ConfigurationAdminController::class, 'getByKey']);
Route::post('/', [ConfigurationAdminController::class, 'store']); Route::post('/', [ConfigurationAdminController::class, 'store']);
@@ -1205,7 +1222,7 @@ Route::prefix('v1')->group(function () {
Route::prefix('incidents')->group(function () { Route::prefix('incidents')->group(function () {
Route::get('current', [IncidentApiController::class, 'current']); Route::get('current', [IncidentApiController::class, 'current']);
Route::get('history', [IncidentApiController::class, 'history']); Route::get('history', [IncidentApiController::class, 'history'])->middleware('admin.access');
Route::get('processed', [IncidentApiController::class, 'processed']); Route::get('processed', [IncidentApiController::class, 'processed']);
Route::get('analysis', [IncidentApiController::class, 'analysis']); Route::get('analysis', [IncidentApiController::class, 'analysis']);
Route::get('grades/{gradeId}/students', [IncidentApiController::class, 'studentsByGrade']); Route::get('grades/{gradeId}/students', [IncidentApiController::class, 'studentsByGrade']);
+6
View File
@@ -6,6 +6,7 @@ use App\Models\Configuration;
use App\Models\Role; use App\Models\Role;
use App\Models\SchoolYear; use App\Models\SchoolYear;
use App\Models\User; use App\Models\User;
use Laravel\Sanctum\Sanctum;
trait CreatesApiTestUsers trait CreatesApiTestUsers
{ {
@@ -68,6 +69,10 @@ trait CreatesApiTestUsers
'status' => 'Active', 'status' => 'Active',
'is_verified' => 1, 'is_verified' => 1,
'is_suspended' => 0, 'is_suspended' => 0,
'address_street' => '1 Test Street',
'city' => 'Brooklyn',
'state' => 'NY',
'zip' => '11201',
'email' => $roleName.'-api-test-'.str_replace('.', '', uniqid('', true)).'@example.test', 'email' => $roleName.'-api-test-'.str_replace('.', '', uniqid('', true)).'@example.test',
], $overrides)); ], $overrides));
@@ -81,6 +86,7 @@ trait CreatesApiTestUsers
{ {
$user = $this->createApiUserWithRole('administrator'); $user = $this->createApiUserWithRole('administrator');
$this->actingAs($user, 'api'); $this->actingAs($user, 'api');
Sanctum::actingAs($user);
return $user; return $user;
} }
@@ -93,10 +93,10 @@ class ApiUseCaseCoverageMatrixTest extends TestCase
'students_parents_and_families' => '#^api/v1/(?:students|parents|families|family-admin|emergency-contacts)(?:/|$)#', 'students_parents_and_families' => '#^api/v1/(?:students|parents|families|family-admin|emergency-contacts)(?:/|$)#',
'teachers_staff_and_class_operations' => '#^api/v1/(?:teacher|teachers|staff|assignments|class-prep|class-progress)(?:/|$)#', 'teachers_staff_and_class_operations' => '#^api/v1/(?:teacher|teachers|staff|assignments|class-prep|class-progress)(?:/|$)#',
'attendance_and_safety' => '#^api/v1/(?:attendance|attendance-tracking|attendance-comment-templates|attendance-templates|incidents)(?:/|$)#', 'attendance_and_safety' => '#^api/v1/(?:attendance|attendance-tracking|attendance-comment-templates|attendance-templates|incidents)(?:/|$)#',
'grading_scores_and_reporting' => '#^api/v1/(?:scores|grading|reports|badges|certificates|print-requests)(?:/|$)#', 'grading_scores_and_reporting' => '#^api/v1/(?:scores|grading|reports|badges|certificates|print-requests|subjects/curriculum|exams/drafts)(?:/|$)#',
'finance_billing_and_inventory' => '#^api/v1/(?:finance|discounts|expenses|extra-charges|inventory|files)(?:/|$)#', 'finance_billing_and_inventory' => '#^api/v1/(?:finance|discounts|expenses|extra-charges|inventory|files)(?:/|$)#',
'communications_and_messaging' => '#^api/v1/(?:messages|broadcast-email|email|email-extractor|communications|support|whatsapp)(?:/|$)#', 'communications_and_messaging' => '#^api/v1/(?:messages|broadcast-email|email|email-extractor|communications|support|whatsapp)(?:/|$)#',
'competition_and_public_results' => '#^api/v1/(?:competition-scores)(?:/|$)#', 'competition_and_public_results' => '#^api/v1/(?:competition-scores|competition-winners)(?:/|$)#',
]; ];
} }
@@ -7,16 +7,17 @@ use App\Models\Student;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum; use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase; use Tests\TestCase;
class EmergencyContactControllerTest extends TestCase class EmergencyContactControllerTest extends TestCase
{ {
use CreatesApiTestUsers;
use RefreshDatabase; use RefreshDatabase;
public function test_index_returns_grouped_contacts(): void public function test_index_returns_grouped_contacts(): void
{ {
$admin = User::factory()->create(); $this->actingAsApiAdministrator();
$parent = User::factory()->create([ $parent = User::factory()->create([
'firstname' => 'Omar', 'firstname' => 'Omar',
'lastname' => 'Parent', 'lastname' => 'Parent',
@@ -54,7 +55,6 @@ class EmergencyContactControllerTest extends TestCase
'school_year' => '2025-2026', 'school_year' => '2025-2026',
]); ]);
Sanctum::actingAs($admin, [], 'api');
$response = $this->getJson('/api/v1/administrator/emergency-contacts'); $response = $this->getJson('/api/v1/administrator/emergency-contacts');
$response->assertOk(); $response->assertOk();
@@ -66,7 +66,7 @@ class EmergencyContactControllerTest extends TestCase
public function test_update_changes_emergency_contact(): void public function test_update_changes_emergency_contact(): void
{ {
$admin = User::factory()->create(); $this->actingAsApiAdministrator();
$parent = User::factory()->create(); $parent = User::factory()->create();
$contact = EmergencyContact::query()->create([ $contact = EmergencyContact::query()->create([
@@ -79,7 +79,6 @@ class EmergencyContactControllerTest extends TestCase
'school_year' => '2025-2026', 'school_year' => '2025-2026',
]); ]);
Sanctum::actingAs($admin, [], 'api');
$response = $this->patch('/api/v1/administrator/emergency-contacts/'.$contact->id, [ $response = $this->patch('/api/v1/administrator/emergency-contacts/'.$contact->id, [
'parent_id' => $parent->id, 'parent_id' => $parent->id,
'name' => 'New Name', 'name' => 'New Name',
@@ -102,7 +101,7 @@ class EmergencyContactControllerTest extends TestCase
public function test_destroy_deletes_emergency_contact(): void public function test_destroy_deletes_emergency_contact(): void
{ {
$admin = User::factory()->create(); $this->actingAsApiAdministrator();
$parent = User::factory()->create(); $parent = User::factory()->create();
$contact = EmergencyContact::query()->create([ $contact = EmergencyContact::query()->create([
@@ -115,7 +114,6 @@ class EmergencyContactControllerTest extends TestCase
'school_year' => '2025-2026', 'school_year' => '2025-2026',
]); ]);
Sanctum::actingAs($admin, [], 'api');
$response = $this->delete('/api/v1/administrator/emergency-contacts/'.$contact->id, [ $response = $this->delete('/api/v1/administrator/emergency-contacts/'.$contact->id, [
'parent_id' => $parent->id, 'parent_id' => $parent->id,
], [ ], [
@@ -131,7 +129,7 @@ class EmergencyContactControllerTest extends TestCase
public function test_show_returns_single_contact_for_parent(): void public function test_show_returns_single_contact_for_parent(): void
{ {
$admin = User::factory()->create(); $this->actingAsApiAdministrator();
$parent = User::factory()->create(); $parent = User::factory()->create();
$contact = EmergencyContact::query()->create([ $contact = EmergencyContact::query()->create([
@@ -144,7 +142,6 @@ class EmergencyContactControllerTest extends TestCase
'school_year' => '2025-2026', 'school_year' => '2025-2026',
]); ]);
Sanctum::actingAs($admin, [], 'api');
$response = $this->get('/api/v1/administrator/emergency-contacts/'.$contact->id.'?parent_id='.$parent->id, [ $response = $this->get('/api/v1/administrator/emergency-contacts/'.$contact->id.'?parent_id='.$parent->id, [
'Accept' => 'application/json', 'Accept' => 'application/json',
]); ]);
@@ -156,7 +153,7 @@ class EmergencyContactControllerTest extends TestCase
public function test_update_requires_matching_parent_id(): void public function test_update_requires_matching_parent_id(): void
{ {
$admin = User::factory()->create(); $this->actingAsApiAdministrator();
$parent = User::factory()->create(); $parent = User::factory()->create();
$otherParent = User::factory()->create(); $otherParent = User::factory()->create();
@@ -170,7 +167,6 @@ class EmergencyContactControllerTest extends TestCase
'school_year' => '2025-2026', 'school_year' => '2025-2026',
]); ]);
Sanctum::actingAs($admin, [], 'api');
$response = $this->patch('/api/v1/administrator/emergency-contacts/'.$contact->id, [ $response = $this->patch('/api/v1/administrator/emergency-contacts/'.$contact->id, [
'parent_id' => $otherParent->id, 'parent_id' => $otherParent->id,
'name' => 'New Name', 'name' => 'New Name',
@@ -190,7 +186,7 @@ class EmergencyContactControllerTest extends TestCase
public function test_destroy_requires_matching_parent_id(): void public function test_destroy_requires_matching_parent_id(): void
{ {
$admin = User::factory()->create(); $this->actingAsApiAdministrator();
$parent = User::factory()->create(); $parent = User::factory()->create();
$otherParent = User::factory()->create(); $otherParent = User::factory()->create();
@@ -204,7 +200,6 @@ class EmergencyContactControllerTest extends TestCase
'school_year' => '2025-2026', 'school_year' => '2025-2026',
]); ]);
Sanctum::actingAs($admin, [], 'api');
$response = $this->delete('/api/v1/administrator/emergency-contacts/'.$contact->id, [ $response = $this->delete('/api/v1/administrator/emergency-contacts/'.$contact->id, [
'parent_id' => $otherParent->id, 'parent_id' => $otherParent->id,
], [ ], [
@@ -36,6 +36,7 @@ class LateSlipLogsControllerTest extends TestCase
'grade' => '3', 'grade' => '3',
'reason' => 'Traffic', 'reason' => 'Traffic',
'admin_name' => 'Admin User', 'admin_name' => 'Admin User',
'printed_at' => now(),
]); ]);
$response = $this->getJson('/api/v1/attendance/late-slip-logs?school_year=2025-2026'); $response = $this->getJson('/api/v1/attendance/late-slip-logs?school_year=2025-2026');
@@ -59,6 +60,7 @@ class LateSlipLogsControllerTest extends TestCase
'grade' => '2', 'grade' => '2',
'reason' => 'Bus delay', 'reason' => 'Bus delay',
'admin_name' => 'Admin User', 'admin_name' => 'Admin User',
'printed_at' => now(),
]); ]);
$response = $this->getJson('/api/v1/attendance/late-slip-logs/1'); $response = $this->getJson('/api/v1/attendance/late-slip-logs/1');
@@ -81,6 +83,7 @@ class LateSlipLogsControllerTest extends TestCase
'grade' => '1', 'grade' => '1',
'reason' => 'Weather', 'reason' => 'Weather',
'admin_name' => 'Admin User', 'admin_name' => 'Admin User',
'printed_at' => now(),
]); ]);
$response = $this->deleteJson('/api/v1/attendance/late-slip-logs/1'); $response = $this->deleteJson('/api/v1/attendance/late-slip-logs/1');
@@ -30,6 +30,7 @@ class IpBanControllerTest extends TestCase
DB::table('ip_attempts')->insert([ DB::table('ip_attempts')->insert([
'ip_address' => '10.0.0.1', 'ip_address' => '10.0.0.1',
'attempts' => 5, 'attempts' => 5,
'last_attempt_at' => now('UTC'),
'blocked_until' => now('UTC')->addHours(2), 'blocked_until' => now('UTC')->addHours(2),
]); ]);
@@ -48,6 +49,7 @@ class IpBanControllerTest extends TestCase
DB::table('ip_attempts')->insert([ DB::table('ip_attempts')->insert([
'ip_address' => '10.0.0.2', 'ip_address' => '10.0.0.2',
'attempts' => 3, 'attempts' => 3,
'last_attempt_at' => now('UTC'),
'blocked_until' => now('UTC')->addHours(1), 'blocked_until' => now('UTC')->addHours(1),
]); ]);
@@ -65,6 +67,7 @@ class IpBanControllerTest extends TestCase
DB::table('ip_attempts')->insert([ DB::table('ip_attempts')->insert([
'ip_address' => '10.0.0.3', 'ip_address' => '10.0.0.3',
'attempts' => 1, 'attempts' => 1,
'last_attempt_at' => now('UTC'),
]); ]);
$response = $this->postJson('/api/v1/ip-bans/ban', [ $response = $this->postJson('/api/v1/ip-bans/ban', [
@@ -87,6 +90,7 @@ class IpBanControllerTest extends TestCase
DB::table('ip_attempts')->insert([ DB::table('ip_attempts')->insert([
'ip_address' => '10.0.0.4', 'ip_address' => '10.0.0.4',
'attempts' => 5, 'attempts' => 5,
'last_attempt_at' => now('UTC'),
'blocked_until' => now('UTC')->addHours(2), 'blocked_until' => now('UTC')->addHours(2),
]); ]);
@@ -111,11 +115,13 @@ class IpBanControllerTest extends TestCase
[ [
'ip_address' => '10.0.0.5', 'ip_address' => '10.0.0.5',
'attempts' => 5, 'attempts' => 5,
'last_attempt_at' => now('UTC'),
'blocked_until' => now('UTC')->addHours(2), 'blocked_until' => now('UTC')->addHours(2),
], ],
[ [
'ip_address' => '10.0.0.6', 'ip_address' => '10.0.0.6',
'attempts' => 2, 'attempts' => 2,
'last_attempt_at' => now('UTC'),
'blocked_until' => now('UTC')->addHours(2), 'blocked_until' => now('UTC')->addHours(2),
], ],
]); ]);
@@ -50,10 +50,14 @@ class ClassPreparationControllerTest extends TestCase
private function seedStickerData(): void private function seedStickerData(): void
{ {
DB::table('configuration')->insert([ DB::table('configuration')->updateOrInsert(
['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], ['config_key' => 'school_year'],
['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], ['config_value' => '2025-2026'],
]); );
DB::table('configuration')->updateOrInsert(
['config_key' => 'semester'],
['config_value' => 'Fall'],
);
DB::table('classes')->insert([ DB::table('classes')->insert([
[ [
@@ -61,6 +61,8 @@ class ClassProgressControllerTest extends TestCase
'covered_quran' => 'Lesson B', 'covered_quran' => 'Lesson B',
'homework_islamic' => 'HW A', 'homework_islamic' => 'HW A',
'homework_quran' => 'HW B', 'homework_quran' => 'HW B',
'unit_islamic' => ['Unit 1'],
'chapter_islamic' => ['Chapter A'],
'flags' => ['needs_support'], 'flags' => ['needs_support'],
]; ];
@@ -74,10 +74,14 @@ class CompetitionScoresControllerTest extends TestCase
private function seedCompetitionData(): void private function seedCompetitionData(): void
{ {
DB::table('configuration')->insert([ DB::table('configuration')->updateOrInsert(
['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], ['config_key' => 'school_year'],
['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], ['config_value' => '2025-2026']
]); );
DB::table('configuration')->updateOrInsert(
['config_key' => 'semester'],
['config_value' => 'Fall']
);
DB::table('classes')->insert([ DB::table('classes')->insert([
'id' => 1, 'id' => 1,
@@ -88,10 +88,14 @@ class CompetitionWinnersControllerTest extends TestCase
private function seedCompetitionData(): void private function seedCompetitionData(): void
{ {
DB::table('configuration')->insert([ DB::table('configuration')->updateOrInsert(
['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], ['config_key' => 'school_year'],
['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], ['config_value' => '2025-2026']
]); );
DB::table('configuration')->updateOrInsert(
['config_key' => 'semester'],
['config_value' => 'Fall']
);
DB::table('classSection')->insert([ DB::table('classSection')->insert([
'id' => 1, 'id' => 1,
@@ -101,10 +101,14 @@ class DiscountControllerTest extends TestCase
private function seedDiscountData(): void private function seedDiscountData(): void
{ {
DB::table('configuration')->insert([ DB::table('configuration')->updateOrInsert(
['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], ['config_key' => 'school_year'],
['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], ['config_value' => '2025-2026']
]); );
DB::table('configuration')->updateOrInsert(
['config_key' => 'semester'],
['config_value' => 'Fall']
);
DB::table('users')->insert([ DB::table('users')->insert([
'id' => 10, 'id' => 10,
@@ -129,6 +129,7 @@ class ExamDraftControllerTest extends TestCase
'class_section_id' => $classSectionId, 'class_section_id' => $classSectionId,
'position' => 'main', 'position' => 'main',
'school_year' => '2025-2026', 'school_year' => '2025-2026',
'semester' => 'Fall',
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
@@ -169,10 +169,14 @@ class ExpenseControllerTest extends TestCase
private function seedExpenseData(): void private function seedExpenseData(): void
{ {
DB::table('configuration')->insert([ DB::table('configuration')->updateOrInsert(
['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], ['config_key' => 'school_year'],
['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], ['config_value' => '2025-2026']
]); );
DB::table('configuration')->updateOrInsert(
['config_key' => 'semester'],
['config_value' => 'Fall']
);
DB::table('users')->insert([ DB::table('users')->insert([
'id' => 2, 'id' => 2,

Some files were not shown because too many files have changed in this diff Show More