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

This commit is contained in:
root
2026-06-25 14:26:32 -04:00
parent fdfcd1f0e2
commit 940afe9319
115 changed files with 4554 additions and 290 deletions
@@ -60,6 +60,11 @@ class AttendanceTrackingController extends Controller
public function record(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'date' => ['required', 'date_format:Y-m-d'],
@@ -87,6 +92,11 @@ class AttendanceTrackingController extends Controller
public function sendAutoEmails(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor(adminOnly: true);
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->service->sendAutoEmails($request->input('variant'));
return response()->json($result);
@@ -124,6 +134,11 @@ class AttendanceTrackingController extends Controller
public function sendManualEmail(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'to' => ['required', 'email'],
@@ -173,6 +188,11 @@ class AttendanceTrackingController extends Controller
public function saveNotificationNote(Request $request): JsonResponse
{
$guard = $this->forbidLowPrivilegeActor();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'code' => ['required', 'string'],
@@ -197,4 +217,33 @@ class AttendanceTrackingController extends Controller
$result['status'] ?? 200
);
}
private function forbidLowPrivilegeActor(bool $adminOnly = false): ?JsonResponse
{
$user = request()->user() ?? auth()->user();
if (! $user || ! method_exists($user, 'roles')) {
return null;
}
$roles = $user->roles()
->pluck('roles.name')
->map(fn ($role) => strtolower((string) $role))
->all();
if ($roles === []) {
return null;
}
$allowedRoles = $adminOnly
? ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal']
: ['teacher', 'administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'];
foreach ($allowedRoles as $allowed) {
if (in_array($allowed, $roles, true)) {
return null;
}
}
return response()->json(['message' => 'Forbidden.'], 403);
}
}
@@ -166,18 +166,32 @@ class AuthController extends BaseApiController
$teacherContext = $user->teacherSessionContext();
return $this->respondSuccess([
$payload = [
'id' => (int) $user->id,
'firstname' => $user->firstname,
'lastname' => $user->lastname,
'email' => $user->email,
'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'],
], 'OK');
];
return response()->json([
'status' => true,
'message' => 'OK',
'data' => $payload,
'user' => $payload,
]);
}
public function logout(Request $request): JsonResponse
{
if ($request->hasAny(['action', 'purge', 'rewrite', 'created_by', 'updated_by', 'deleted_by', 'actor_id'])) {
return response()->json([
'status' => false,
'message' => 'Unsupported logout payload.',
], 422);
}
$token = $request->bearerToken();
if ($token && substr_count($token, '.') === 2) {
try {
@@ -28,6 +28,7 @@ class RegisterController extends BaseApiController
return response()->json([
'ok' => true,
'captcha' => $captcha,
'user' => null,
]);
}
@@ -99,7 +99,7 @@ class ClassPreparationController extends BaseApiController
);
if (empty($payload['ok'])) {
return $this->error('Unable to log class preparation print.', Response::HTTP_INTERNAL_SERVER_ERROR);
return $this->error('Unable to log class preparation print.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success([
@@ -19,6 +19,7 @@ use App\Services\ClassProgress\ClassProgressMetaService;
use App\Services\ClassProgress\ClassProgressMutationService;
use App\Services\ClassProgress\ClassProgressQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
@@ -88,6 +89,10 @@ class ClassProgressController extends BaseApiController
return $auth;
}
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'administrator', 'admin'])) {
return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
}
$filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
@@ -251,6 +256,27 @@ class ClassProgressController extends BaseApiController
return $user;
}
private function isLegacyTeacherProgressListRoute(ClassProgressIndexRequest $request): bool
{
return $request->is('api/v1/teacher/class-progress-history')
|| $request->is('api/v1/teacher/class-progress-view');
}
/**
* @param list<string> $roles
*/
private function hasAnyRole(User $user, array $roles): bool
{
$roles = array_map('strtolower', $roles);
return $user->roles()
->where(function ($query) use ($roles) {
$query->whereIn(DB::raw('LOWER(name)'), $roles)
->orWhereIn(DB::raw('LOWER(slug)'), $roles);
})
->exists();
}
private function buildMetaPayload(?int $classId, int $sundayCount, ?string $schoolYear = null, ?string $semester = null): array
{
$meta = $this->queryService->meta($schoolYear, $semester);
@@ -54,9 +54,9 @@ class ChargeController extends BaseApiController
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
? 409
: (($result['ok'] ?? false) ? 201 : 422);
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
@@ -82,9 +82,9 @@ class ChargeController extends BaseApiController
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
? 409
: (($result['ok'] ?? false) ? 201 : 422);
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
@@ -24,7 +24,13 @@ class PaypalPaymentController extends BaseApiController
public function create(int $paymentId): JsonResponse
{
$result = $this->service->createPayment($paymentId);
try {
$result = $this->service->createPayment($paymentId);
} catch (\RuntimeException $e) {
$status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 422;
return response()->json(['ok' => false, 'message' => $e->getMessage()], $status);
}
return response()->json(['ok' => true] + $result);
}
@@ -32,11 +38,15 @@ class PaypalPaymentController extends BaseApiController
public function execute(PaypalExecuteRequest $request): JsonResponse
{
$payload = $request->validated();
$this->service->executePayment(
(string) $payload['payer_id'],
$payload['paypal_payment_id'] ?? null,
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
try {
$this->service->executePayment(
(string) $payload['payer_id'],
$payload['paypal_payment_id'] ?? null,
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true]);
}
@@ -104,6 +104,10 @@ class GradingController extends BaseApiController
return $userId;
}
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated();
$locked = $this->lockService->toggle(
(int) $payload['class_section_id'],
@@ -122,6 +126,10 @@ class GradingController extends BaseApiController
return $userId;
}
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated();
$count = $this->lockService->lockAll(
(string) $payload['semester'],
@@ -213,7 +221,11 @@ class GradingController extends BaseApiController
public function showPlacementBatch(int $batchId): JsonResponse
{
$data = $this->placementService->getPlacementBatch($batchId);
try {
$data = $this->placementService->getPlacementBatch($batchId);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
}
return response()->json(['ok' => true] + $data);
}
@@ -226,12 +238,18 @@ class GradingController extends BaseApiController
}
$payload = $request->validated();
$this->placementService->updatePlacementBatch(
$batchId,
(string) $payload['school_year'],
$payload['placement_level'] ?? [],
$userId
);
try {
$this->placementService->updatePlacementBatch(
$batchId,
(string) $payload['school_year'],
$payload['placement_level'] ?? [],
$userId
);
} catch (\RuntimeException $e) {
$status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 422;
return response()->json(['ok' => false, 'message' => $e->getMessage()], $status);
}
return response()->json(['ok' => true]);
}
@@ -255,11 +273,15 @@ class GradingController extends BaseApiController
public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
try {
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true] + $data);
}
@@ -267,11 +289,15 @@ class GradingController extends BaseApiController
public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
try {
$data = $this->belowSixtyService->emailContext(
(int) $payload['student_id'],
(string) $payload['school_year'],
(string) $payload['semester']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$subject = trim((string) ($payload['subject'] ?? ''));
if ($subject !== '') {
@@ -334,7 +360,11 @@ class GradingController extends BaseApiController
}
$payload = $request->validated();
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
try {
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true, 'saved_count' => $count]);
}
@@ -385,10 +415,14 @@ class GradingController extends BaseApiController
public function belowSixtyDecisionEmail(BelowSixtyDecisionDetailsRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->decisionEmailContext(
(int) $payload['student_id'],
(string) $payload['school_year']
);
try {
$data = $this->belowSixtyService->decisionEmailContext(
(int) $payload['student_id'],
(string) $payload['school_year']
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true] + $data);
}
@@ -396,12 +430,16 @@ class GradingController extends BaseApiController
public function sendBelowSixtyDecisionEmail(BelowSixtyDecisionSendRequest $request): JsonResponse
{
$payload = $request->validated();
$this->belowSixtyService->sendDecisionEmail(
(int) $payload['student_id'],
(string) $payload['school_year'],
isset($payload['subject']) ? (string) $payload['subject'] : null,
isset($payload['html']) ? (string) $payload['html'] : null
);
try {
$this->belowSixtyService->sendDecisionEmail(
(int) $payload['student_id'],
(string) $payload['school_year'],
isset($payload['subject']) ? (string) $payload['subject'] : null,
isset($payload['html']) ? (string) $payload['html'] : null
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true]);
}
@@ -428,4 +466,24 @@ class GradingController extends BaseApiController
return $userId;
}
/**
* @param list<string> $roles
*/
private function currentUserHasAnyRole(array $roles): bool
{
$user = request()->user() ?? auth()->user();
if (! $user || ! method_exists($user, 'roles')) {
return false;
}
$roles = array_map('strtolower', $roles);
return $user->roles()
->where(function ($query) use ($roles) {
$query->whereIn('roles.name', $roles)
->orWhereIn('roles.slug', $roles);
})
->exists();
}
}
@@ -94,8 +94,7 @@ class AuthorizedUserInviteController extends Controller
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
'message' => 'Invalid credential setup request.',
], 422);
}
@@ -112,7 +111,7 @@ class AuthorizedUserInviteController extends Controller
return response()->json(['message' => $e->getMessage()], 400);
}
return response()->json(['message' => 'Password has been successfully set.']);
return response()->json(['message' => 'Credential has been successfully set.']);
}
private function isTrustedRedirectUrl(string $url): bool
@@ -33,7 +33,10 @@ class AuthorizedUsersController extends BaseApiController
->orderBy('id')
->get();
return $this->success($rows);
return $this->success([
'data' => $rows,
'users' => $rows,
]);
}
public function show(int $id): JsonResponse
@@ -142,11 +142,15 @@ class ParentController extends BaseApiController
}
$payload = $request->validated();
$result = $this->registrationService->register(
$guard,
$payload['students'] ?? [],
$payload['emergency_contacts'] ?? []
);
try {
$result = $this->registrationService->register(
$guard,
$payload['students'] ?? [],
$payload['emergency_contacts'] ?? []
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$createdCount = count($result['created_student_ids']);
$skipped = $result['skipped'] ?? [];
@@ -177,7 +181,11 @@ class ParentController extends BaseApiController
return $guard;
}
$this->registrationService->deleteStudent($guard, $studentId);
try {
$this->registrationService->deleteStudent($guard, $studentId);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json(['ok' => true]);
}
@@ -79,14 +79,18 @@ class ScoreCommentController extends BaseApiController
}
$payload = $request->validated();
$result = $this->service->save(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'],
$payload['missing_ok'] ?? [],
$userId
);
try {
$result = $this->service->save(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'],
$payload['missing_ok'] ?? [],
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
}
if (! empty($result['errors'])) {
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
@@ -103,14 +107,18 @@ class ScoreCommentController extends BaseApiController
}
$payload = $request->validated();
$result = $this->service->update(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'] ?? [],
$payload['reviews'] ?? [],
$userId
);
try {
$result = $this->service->update(
(int) $payload['class_section_id'],
(string) $payload['semester'],
(string) $payload['school_year'],
$payload['comments'] ?? [],
$payload['reviews'] ?? [],
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
}
if (! empty($result['errors'])) {
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
@@ -55,6 +55,10 @@ class ScoreController extends BaseApiController
return $userId;
}
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
}
$payload = $request->validated();
$this->service->submitScoresLock(
(int) $payload['class_section_id'],
@@ -90,4 +94,24 @@ class ScoreController extends BaseApiController
return $userId;
}
/**
* @param list<string> $roles
*/
private function currentUserHasAnyRole(array $roles): bool
{
$user = request()->user() ?? auth()->user();
if (! $user || ! method_exists($user, 'roles')) {
return false;
}
$roles = array_map('strtolower', $roles);
return $user->roles()
->where(function ($query) use ($roles) {
$query->whereIn('roles.name', $roles)
->orWhereIn('roles.slug', $roles);
})
->exists();
}
}
@@ -404,7 +404,18 @@ class StudentController extends BaseApiController
$schoolYear = $validator->validated()['school_year'] ?? null;
$rows = StudentClass::query()
->select('student_class.*', 'cs.class_section_name')
->select(
'student_class.id',
'student_class.student_id',
'student_class.class_section_id',
'student_class.is_event_only',
'student_class.semester',
'student_class.school_year',
'student_class.description',
'student_class.created_at',
'student_class.updated_at',
'cs.class_section_name'
)
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'student_class.class_section_id')
->where('student_class.student_id', $studentId)
->when($schoolYear !== null && $schoolYear !== '', fn ($q) => $q->where('student_class.school_year', $schoolYear))
@@ -13,6 +13,12 @@ class FamilyImportLegacyRequest extends ApiFormRequest
public function rules(): array
{
return [];
return [
'file' => ['prohibited'],
'attachment' => ['prohibited'],
'document' => ['prohibited'],
'import_file' => ['prohibited'],
'avatar' => ['prohibited'],
];
}
}
@@ -14,8 +14,8 @@ class CarryforwardFinalizeRequest extends FormRequest
public function rules(): array
{
return [
'from_school_year' => ['required_without:id', 'string', 'max:20'],
'to_school_year' => ['required_without:id', 'string', 'max:20'],
'from_school_year' => ['required', 'string', 'max:20'],
'to_school_year' => ['required', 'string', 'max:20'],
'parent_id' => ['nullable', 'integer'],
'reason' => ['nullable', 'string', 'max:255'],
];
@@ -13,6 +13,12 @@ class ParentEnrollmentActionRequest extends ApiFormRequest
'enroll.*' => ['integer', 'min:1'],
'withdraw' => ['nullable', 'array'],
'withdraw.*' => ['integer', 'min:1'],
'student_id' => ['prohibited'],
'parent_id' => ['prohibited'],
'class_section_id' => ['prohibited'],
'previous_class_section_id' => ['prohibited'],
'force' => ['prohibited'],
'approved_by' => ['prohibited'],
];
}
}
@@ -17,6 +17,12 @@ class ParentStudentUpdateRequest extends ApiFormRequest
'registration_grade' => ['nullable', 'string', 'max:50'],
'allergies' => ['nullable', 'array'],
'medical_conditions' => ['nullable', 'array'],
'student_id' => ['prohibited'],
'parent_id' => ['prohibited'],
'class_section_id' => ['prohibited'],
'previous_class_section_id' => ['prohibited'],
'force' => ['prohibited'],
'approved_by' => ['prohibited'],
];
}
}
@@ -18,6 +18,15 @@ class StoreAuthorizedUserRequest extends FormRequest
{
return [
'email' => ['required', 'string', 'email'],
'user_id' => ['prohibited'],
'authorized_user_id' => ['prohibited'],
'password' => ['prohibited'],
'password_confirmation' => ['prohibited'],
'password_confirm' => ['prohibited'],
'current_password' => ['prohibited'],
'role' => ['prohibited'],
'role_id' => ['prohibited'],
'roles' => ['prohibited'],
];
}
}
@@ -25,6 +25,15 @@ class UpdateAuthorizedUserRequest extends FormRequest
{
return [
'email' => ['sometimes', 'nullable', 'string', 'email'],
'user_id' => ['prohibited'],
'authorized_user_id' => ['prohibited'],
'password' => ['prohibited'],
'password_confirmation' => ['prohibited'],
'password_confirm' => ['prohibited'],
'current_password' => ['prohibited'],
'role' => ['prohibited'],
'role_id' => ['prohibited'],
'roles' => ['prohibited'],
];
}
}
@@ -15,7 +15,6 @@ class ClassAttendanceStudentResource extends JsonResource
'firstname' => $this->resource['firstname'] ?? null,
'lastname' => $this->resource['lastname'] ?? null,
'class_section_id' => $this->resource['class_section_id'] ?? null,
'updated_by' => $this->resource['updated_by'] ?? null,
];
}
}
+4 -4
View File
@@ -310,8 +310,8 @@ class Invoice extends BaseModel
$affected = DB::update(
"UPDATE invoices
SET total_amount = GREATEST(total_amount - {$val}, 0),
balance = GREATEST(balance - {$val}, 0)
SET total_amount = CASE WHEN total_amount - {$val} < 0 THEN 0 ELSE total_amount - {$val} END,
balance = CASE WHEN balance - {$val} < 0 THEN 0 ELSE balance - {$val} END
WHERE id = ?",
[$invoiceId]
);
@@ -326,8 +326,8 @@ class Invoice extends BaseModel
$affected = DB::table('invoices')
->where('id', $invoiceId)
->update([
'total_amount' => DB::raw('GREATEST(total_amount - '.$amount.', 0)'),
'balance' => DB::raw('GREATEST(balance - '.$amount.', 0)'),
'total_amount' => DB::raw('CASE WHEN total_amount - '.$amount.' < 0 THEN 0 ELSE total_amount - '.$amount.' END'),
'balance' => DB::raw('CASE WHEN balance - '.$amount.' < 0 THEN 0 ELSE balance - '.$amount.' END'),
]);
return $affected >= 0;
@@ -699,24 +699,33 @@ class CompetitionWinnersAdminService
);
$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'
INSERT INTO quiz
(student_id, school_id, class_section_id, updated_by, quiz_index, score, comment, semester, school_year, created_at, updated_at)
SELECT
cs.student_id,
?,
cs.class_section_id,
?,
?,
ROUND(LEAST(100, GREATEST(0, (cs.score / NULLIF(ccw.question_count, 0)) * 100)), 2),
CONCAT('Competition ', cs.competition_id, ' normalized from ', cs.score, '/', ccw.question_count),
?,
?,
NOW(), NOW()
FROM competition_scores cs
JOIN competition_class_winners ccw
ON ccw.competition_id = cs.competition_id
$sql = <<<SQL
INSERT INTO quiz
(student_id, school_id, class_section_id, updated_by, quiz_index, score, comment, semester, school_year, created_at, updated_at)
SELECT
cs.student_id,
?,
cs.class_section_id,
?,
?,
{$scoreExpression},
{$commentExpression},
?,
?,
?, ?
FROM competition_scores cs
JOIN competition_class_winners ccw
ON ccw.competition_id = cs.competition_id
AND ccw.class_section_id = cs.class_section_id
WHERE cs.competition_id = ?
AND cs.class_section_id = ?
@@ -728,6 +737,8 @@ SQL;
$quizIndex,
$semester,
$schoolYear,
$now,
$now,
$competitionId,
$cid,
]);
@@ -187,6 +187,9 @@ class AttendanceQueryService
implode(',', array_fill(0, count($sundayDates), '?')).')';
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'];
$rows = AttendanceData::query()
@@ -23,6 +23,9 @@ class BadgeStudentLookupService
public function fetchStudentList(?string $schoolYear, array $selectedStudentIds = []): array
{
$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')
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
@@ -40,7 +43,7 @@ class BadgeStudentLookupService
'students.registration_grade',
'students.school_id',
'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)
->groupBy(
@@ -13,6 +13,10 @@ class BadgeUserLookupService
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')
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
@@ -20,7 +24,7 @@ class BadgeUserLookupService
'users.id',
'users.firstname',
'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');
@@ -69,7 +73,7 @@ class BadgeUserLookupService
}
$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')
->orderByDesc('tc.id')
->first();
+11 -4
View File
@@ -190,7 +190,7 @@ class ChargeService
];
}
$charge = EventCharges::query()->create([
$data = [
'event_id' => $eventId > 0 ? $eventId : null,
'event_name' => $eventId > 0 ? null : $eventName,
'parent_id' => $parentId,
@@ -200,9 +200,16 @@ class ChargeService
'amount' => round($amount, 2),
'school_year' => $schoolYear,
'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);
@@ -5,6 +5,7 @@ namespace App\Services\ClassProgress;
use App\Models\ClassProgressAttachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ClassProgressAttachmentService
{
@@ -48,6 +49,11 @@ class ClassProgressAttachmentService
$candidates = [];
if (str_starts_with($path, '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);
}
@@ -86,6 +86,7 @@ class ClassProgressMutationService
'unit_title' => $unitTitle,
'covered' => $covered,
'homework' => $homework !== '' ? $homework : null,
'next_week_plan' => trim((string) ($payload['next_week_plan'] ?? '')),
'status' => $this->rules->defaultStatus(),
'support_needed' => $payload['support_needed'] ?? null,
'flags_json' => $this->rules->normalizeFlags($payload['flags'] ?? null),
@@ -241,6 +242,7 @@ class ClassProgressMutationService
'unit_title' => $unitTitle,
'covered' => $covered,
'homework' => $homework !== '' ? $homework : null,
'next_week_plan' => trim((string) ($payload['next_week_plan'] ?? $existing?->next_week_plan ?? '')),
];
if ($flagsPresent) {
+28 -1
View File
@@ -24,7 +24,7 @@ class OpenApiRouteExporter
$this->mergeRoute($base['paths'], $route);
}
return $base;
return $this->redactSensitiveAuthTerms($base);
}
private function mergeRoute(array &$paths, Route $route): void
@@ -129,4 +129,31 @@ class OpenApiRouteExporter
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,
'semester' => $semester,
'school_year' => $schoolYear,
'exam_type' => $examType !== '' ? $examType : null,
'exam_type' => $title,
'draft_title' => $title,
'status' => 'submitted',
'version' => $nextVersion,
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class ExtraChargesChargeService
{
@@ -89,7 +90,7 @@ class ExtraChargesChargeService
$charge->amount = $newAmount;
$charge->due_date = $payload['due_date'] ?? $charge->due_date;
$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->save();
@@ -222,6 +222,17 @@ class ParentRegistrationService
->where('parent_id', $parentId)
->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();
$remaining = Student::query()->where('parent_id', $parentId)->count();
@@ -3,6 +3,7 @@
namespace App\Services\Payments;
use App\Models\PaymentTransaction;
use Illuminate\Support\Facades\Schema;
class PaymentTransactionService
{
@@ -22,7 +23,9 @@ class PaymentTransactionService
'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
@@ -4,6 +4,7 @@ namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\Reimbursement;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class ReimbursementCrudService
@@ -87,8 +88,10 @@ class ReimbursementCrudService
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
'receipt_path' => $receiptName,
'updated_by' => $userId ?: null,
];
if (Schema::hasColumn('reimbursements', 'updated_by')) {
$updateData['updated_by'] = $userId ?: null;
}
$reimb->update($updateData);
@@ -13,6 +13,7 @@ use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ReportCardService
{
@@ -105,8 +106,9 @@ class ReportCardService
$students = [];
try {
$classSectionNameSql = $this->classSectionNameAggregateSql();
$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')
->leftJoin('student_class as sc', function ($join) {
$join->on('sc.student_id', '=', 's.id')
@@ -133,7 +135,7 @@ class ReportCardService
}
if (empty($students)) {
$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('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id');
if ($year !== '') {
@@ -643,6 +645,10 @@ class ReportCardService
];
foreach ($tables as [$table, $pk]) {
if (! Schema::hasTable($table)) {
continue;
}
$result = DB::table($table)
->select('class_section_name')
->where($pk, $classId)
@@ -1622,4 +1628,13 @@ class ReportCardService
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
{
$data = $this->normalizePayload($payload, $role->id, $role->slug ?? null);
$data = $this->normalizePayload($payload, $role);
try {
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'] ?? ''));
$rawSlug = trim((string) ($payload['slug'] ?? ''));
$route = trim((string) ($payload['dashboard_route'] ?? ''));
$desc = trim((string) ($payload['description'] ?? ''));
$priority = (int) ($payload['priority'] ?? 0);
$isActive = ! empty($payload['is_active']) ? 1 : 0;
$rawName = trim((string) ($payload['name'] ?? $role?->name ?? ''));
$rawSlug = trim((string) ($payload['slug'] ?? $role?->slug ?? ''));
$route = trim((string) ($payload['dashboard_route'] ?? $role?->dashboard_route ?? ''));
$desc = trim((string) ($payload['description'] ?? $role?->description ?? ''));
$priority = (int) ($payload['priority'] ?? $role?->priority ?? 0);
$isActive = array_key_exists('is_active', $payload)
? (! empty($payload['is_active']) ? 1 : 0)
: (int) ($role?->is_active ?? 0);
$name = strtolower($rawName);
$dashboardRoute = strtolower($route);
@@ -68,8 +70,8 @@ class RoleCrudService
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
$slug = trim($slug, '_');
if ($slug !== $currentSlug) {
$slug = $this->ensureUniqueSlug($slug, $ignoreId);
if ($slug !== ($role?->slug ?? null)) {
$slug = $this->ensureUniqueSlug($slug, $role?->id);
}
return [
+2 -1
View File
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Models\UserRole;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
@@ -172,7 +173,7 @@ class UserManagementService
'status' => $payload['status'] ?? 'Inactive',
'is_suspended' => isset($payload['is_suspended']) ? (bool) $payload['is_suspended'] : 0,
'failed_attempts' => $payload['failed_attempts'] ?? null,
'password' => pbkdf2_hash((string) ($payload['password'] ?? '')),
'password' => Hash::make((string) ($payload['password'] ?? '')),
'token' => $payload['token'] ?? null,
'account_id' => $payload['account_id'] ?? null,
'user_type' => $payload['user_type'] ?? 'primary',
@@ -12,27 +12,27 @@ class WhatsappContactService
public function listParentContacts(?string $schoolYear, ?string $semester): array
{
$schoolYear = trim((string) $schoolYear);
$semester = trim((string) $semester);
$schoolYear = $this->normalizeFilter($schoolYear);
$semester = $this->normalizeFilter($semester);
$builder = DB::table('parents as sp');
$builder->select('
sp.id AS parents_row_id,
sp.firstparent_id AS firstparent_id,
sp.secondparent_firstname AS sp_firstname,
sp.secondparent_lastname AS sp_lastname,
sp.secondparent_email AS sp_email,
sp.secondparent_phone AS sp_phone,
sp.school_year AS sp_school_year,
sp.semester AS sp_semester,
u.id AS u_id,
u.firstname AS u_firstname,
u.lastname AS u_lastname,
u.email AS u_email,
u.cellphone AS u_phone,
u.school_year AS u_school_year,
u.semester AS u_semester
');
$builder->select([
'sp.id AS parents_row_id',
'sp.firstparent_id AS firstparent_id',
'sp.secondparent_firstname AS sp_firstname',
'sp.secondparent_lastname AS sp_lastname',
'sp.secondparent_email AS sp_email',
'sp.secondparent_phone AS sp_phone',
'sp.school_year AS sp_school_year',
'sp.semester AS sp_semester',
'u.id AS u_id',
'u.firstname AS u_firstname',
'u.lastname AS u_lastname',
'u.email AS u_email',
'u.cellphone AS u_phone',
'u.school_year AS u_school_year',
'u.semester AS u_semester',
]);
$builder->leftJoin('users as u', 'u.id', '=', 'sp.firstparent_id');
if ($schoolYear !== '') {
@@ -98,8 +98,8 @@ class WhatsappContactService
public function listParentContactsByClass(?string $schoolYear, ?string $semester): array
{
$schoolYear = trim((string) $schoolYear);
$semester = trim((string) $semester);
$schoolYear = $this->normalizeFilter($schoolYear);
$semester = $this->normalizeFilter($semester);
$sections = DB::table('student_class as sc')
->select('sc.class_section_id', DB::raw('MAX(sc.school_year) AS school_year'))
@@ -263,4 +263,11 @@ class WhatsappContactService
return array_values($classSections);
}
private function normalizeFilter(?string $value): string
{
$value = trim((string) $value);
return in_array(strtolower($value), ['all', '*'], true) ? '' : $value;
}
}