diff --git a/app/Config/Database.php b/app/Config/Database.php old mode 100755 new mode 100644 diff --git a/app/Config/Services.php b/app/Config/Services.php old mode 100755 new mode 100644 diff --git a/app/Config/SessionTimeout.php b/app/Config/SessionTimeout.php old mode 100755 new mode 100644 diff --git a/app/Helpers/ci_helpers.php b/app/Helpers/ci_helpers.php old mode 100755 new mode 100644 diff --git a/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php b/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php index 1f6ba3f4..7f841d28 100644 --- a/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php +++ b/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Administrator; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Teachers\TeacherAssignmentDeleteRequest; use App\Http\Requests\Teachers\TeacherAssignmentListRequest; use App\Http\Requests\Teachers\TeacherAssignmentStoreRequest; @@ -32,8 +32,13 @@ class TeacherClassAssignmentController extends BaseApiController public function store(TeacherAssignmentStoreRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); - $payload['updated_by'] = (int) (auth()->id() ?? 0); + $payload['updated_by'] = $userId; $result = $this->assignmentService->assign($payload); $status = $result['ok'] ? 200 : 422; @@ -55,4 +60,17 @@ class TeacherClassAssignmentController extends BaseApiController 'message' => $result['message'] ?? '', ], $status); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Assignment/AssignmentApiController.php b/app/Http/Controllers/Api/Assignment/AssignmentApiController.php index ca484af7..a4bee0ea 100644 --- a/app/Http/Controllers/Api/Assignment/AssignmentApiController.php +++ b/app/Http/Controllers/Api/Assignment/AssignmentApiController.php @@ -46,9 +46,14 @@ class AssignmentApiController extends Controller ], 422); } + $userId = (int) ($request->user()?->id ?? 0); + if ($userId <= 0) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + $assignment = $this->assignmentService->storeAssignment( data: $validator->validated(), - updatedBy: $request->user()?->id + updatedBy: $userId ); return response()->json([ diff --git a/app/Http/Controllers/Api/Attendance/AdminAttendanceApiController.php b/app/Http/Controllers/Api/Attendance/AdminAttendanceApiController.php index ec2d3321..9530777e 100644 --- a/app/Http/Controllers/Api/Attendance/AdminAttendanceApiController.php +++ b/app/Http/Controllers/Api/Attendance/AdminAttendanceApiController.php @@ -31,9 +31,14 @@ class AdminAttendanceApiController extends Controller public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse { + $user = auth()->user(); + if ($user === null) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + try { return response()->json( - $this->attendanceService->updateAttendanceManagement(auth()->user(), $request->validated()) + $this->attendanceService->updateAttendanceManagement($user, $request->validated()) ); } catch (Throwable $e) { return response()->json(['message' => $e->getMessage()], 422); @@ -42,9 +47,14 @@ class AdminAttendanceApiController extends Controller public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse { + $user = auth()->user(); + if ($user === null) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + try { return response()->json( - $this->attendanceService->adminAddEntry(auth()->user(), $request->validated()) + $this->attendanceService->adminAddEntry($user, $request->validated()) ); } catch (Throwable $e) { return response()->json(['message' => $e->getMessage()], 422); diff --git a/app/Http/Controllers/Api/Attendance/AttendanceCommentTemplateController.php b/app/Http/Controllers/Api/Attendance/AttendanceCommentTemplateController.php index 57cbb4d5..019a8fcb 100644 --- a/app/Http/Controllers/Api/Attendance/AttendanceCommentTemplateController.php +++ b/app/Http/Controllers/Api/Attendance/AttendanceCommentTemplateController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Attendance; use App\Http\Controllers\Controller; +use App\Services\ApplicationUrlService; use App\Services\AttendanceCommentTemplateService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -11,10 +12,22 @@ use Illuminate\Support\Facades\Validator; class AttendanceCommentTemplateController extends Controller { public function __construct( - protected AttendanceCommentTemplateService $service + protected AttendanceCommentTemplateService $service, + protected ApplicationUrlService $urls, ) { } + /** + * Bootstrap URLs for admin UIs (legacy CodeIgniter parity + SPA). + */ + public function bootstrapUrls(): JsonResponse + { + return response()->json([ + 'status' => true, + 'data' => $this->urls->attendanceCommentTemplateBootstrapData(), + ]); + } + public function index(Request $request): JsonResponse { $activeOnly = filter_var( @@ -119,4 +132,83 @@ class AttendanceCommentTemplateController extends Controller 'message' => 'Template deleted successfully.', ]); } + + /** + * CodeIgniter {@see \App\Controllers\View\AttendanceCommentTemplateController::save} + * POST fields: id?, min_score, max_score, template_text, is_active (checkbox "on"). + */ + public function legacySave(Request $request): JsonResponse + { + $id = $request->input('id'); + $hasId = $id !== null && $id !== '' && (int) $id > 0; + + $rules = [ + 'min_score' => ['required', 'integer', 'min:0', 'max:100'], + 'max_score' => ['required', 'integer', 'min:0', 'max:100'], + 'template_text' => ['required', 'string', 'max:5000'], + ]; + + $validator = Validator::make($request->all(), $rules); + + $validator->after(function ($v) use ($request) { + $min = (int) $request->input('min_score'); + $max = (int) $request->input('max_score'); + if ($max < $min) { + $v->errors()->add('max_score', 'The max_score field must be greater than or equal to min_score.'); + } + }); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $isActive = $this->legacyIsActive($request); + + $payload = [ + 'min_score' => (int) $request->input('min_score'), + 'max_score' => (int) $request->input('max_score'), + 'template_text' => (string) $request->input('template_text'), + 'is_active' => $isActive, + ]; + + if ($hasId) { + $this->service->update((int) $id, $payload); + } else { + $this->service->create($payload); + } + + return response()->json(['status' => 'success']); + } + + /** + * CodeIgniter {@see \App\Controllers\View\AttendanceCommentTemplateController::delete}. + */ + public function legacyDelete(Request $request): JsonResponse + { + $id = $request->input('id'); + if ($id === null || $id === '' || (int) $id <= 0) { + return response()->json([ + 'status' => 'error', + 'message' => 'ID not provided', + ], 400); + } + + $this->service->delete((int) $id); + + return response()->json(['status' => 'success']); + } + + private function legacyIsActive(Request $request): bool + { + $raw = $request->input('is_active'); + + return $raw === 'on' + || $raw === 1 + || $raw === '1' + || $raw === true + || $raw === 'true'; + } } diff --git a/app/Http/Controllers/Api/Attendance/LateSlipLogsController.php b/app/Http/Controllers/Api/Attendance/LateSlipLogsController.php index bec7e4f9..ba8d3b8e 100644 --- a/app/Http/Controllers/Api/Attendance/LateSlipLogsController.php +++ b/app/Http/Controllers/Api/Attendance/LateSlipLogsController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Attendance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Attendance\LateSlipLogIndexRequest; use App\Http\Resources\Attendance\LateSlipLogCollection; use App\Http\Resources\Attendance\LateSlipLogResource; diff --git a/app/Http/Controllers/Api/Attendance/StaffAttendanceApiController.php b/app/Http/Controllers/Api/Attendance/StaffAttendanceApiController.php index 42088602..5fa4ae9f 100644 --- a/app/Http/Controllers/Api/Attendance/StaffAttendanceApiController.php +++ b/app/Http/Controllers/Api/Attendance/StaffAttendanceApiController.php @@ -42,9 +42,14 @@ class StaffAttendanceApiController extends Controller public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse { + $user = auth()->user(); + if ($user === null) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + try { return response()->json( - $this->staffAttendanceService->saveAdmins(auth()->user(), $request->validated()) + $this->staffAttendanceService->saveAdmins($user, $request->validated()) ); } catch (Throwable $e) { return response()->json(['message' => $e->getMessage()], 422); @@ -53,9 +58,14 @@ class StaffAttendanceApiController extends Controller public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse { + $user = auth()->user(); + if ($user === null) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + try { return response()->json( - $this->staffAttendanceService->saveCell(auth()->user(), $request->validated()) + $this->staffAttendanceService->saveCell($user, $request->validated()) ); } catch (Throwable $e) { return response()->json(['message' => $e->getMessage()], 422); diff --git a/app/Http/Controllers/Api/Attendance/TeacherAttendanceApiController.php b/app/Http/Controllers/Api/Attendance/TeacherAttendanceApiController.php index 076cd565..84170381 100644 --- a/app/Http/Controllers/Api/Attendance/TeacherAttendanceApiController.php +++ b/app/Http/Controllers/Api/Attendance/TeacherAttendanceApiController.php @@ -33,10 +33,15 @@ class TeacherAttendanceApiController extends Controller public function form(Request $request): JsonResponse|TeacherAttendanceFormResource { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + try { return new TeacherAttendanceFormResource( $this->queryService->teacherAttendanceFormData( - auth()->id(), + $guard, (int)$request->query('class_section_id', 0) ) ); @@ -47,12 +52,30 @@ class TeacherAttendanceApiController extends Controller public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse { + $user = auth()->user(); + if ($user === null) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + try { return response()->json( - $this->attendanceService->submitTeacherAttendance(auth()->user(), $request->validated()) + $this->attendanceService->submitTeacherAttendance($user, $request->validated()) ); } catch (Throwable $e) { return response()->json(['message' => $e->getMessage()], 422); } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + + return $userId; + } } \ No newline at end of file diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index 1228831e..f7447f8e 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -2,69 +2,83 @@ namespace App\Http\Controllers\Api\Auth; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Models\User; +use App\Services\Auth\ApiLoginSecurityService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Validator; use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; class AuthController extends BaseApiController { - public function login(Request $request): JsonResponse + public function login(Request $request, ApiLoginSecurityService $security): JsonResponse { - $validator = Validator::make($request->all(), [ - 'email' => ['required', 'email'], - 'password' => ['required', 'string'], - ]); - - if ($validator->fails()) { - return $this->respondValidationError($validator->errors()->toArray()); + $payload = $request->all(); + if ($request->isJson() && ($payload === [] || $payload === null)) { + $decoded = json_decode((string) $request->getContent(), true); + $payload = is_array($decoded) ? $decoded : []; } - $email = (string) $request->input('email'); - $password = (string) $request->input('password'); + // Match CodeIgniter apiLogin: raw email/password (no trim); empty check is identical to CI `!$email || !$password`. + $email = (string) ($payload['email'] ?? ''); + $password = (string) ($payload['password'] ?? ''); + + if ($email === '' || $password === '') { + return response()->json([ + 'status' => false, + 'message' => 'Email and password are required.', + ], 400); + } + + $ip = $request->ip(); + if ($security->isIpBlocked((string) $ip)) { + return response()->json([ + 'status' => false, + 'message' => 'Too many failed attempts from your IP. Please try again later.', + ], 429); + } $user = User::query()->where('email', $email)->first(); - if (!$user || !ci_password_verify($password, (string) $user->password)) { - return $this->respondError('Invalid credentials.', 401); + if (!$user) { + $security->logIpAttempt((string) $ip); + + return response()->json([ + 'status' => false, + 'message' => 'Invalid email or password.', + ], 401); } - if ((int) ($user->is_verified ?? 0) !== 1) { - return $this->respondError('Account not verified.', 403); + if ($user->is_suspended) { + return response()->json([ + 'status' => false, + 'message' => 'Account suspended. Please reset your password.', + ], 403); } - if (strcasecmp((string) ($user->status ?? ''), 'Active') !== 0) { - return $this->respondError('Account is inactive.', 403); + if (! ci_password_verify($password, (string) $user->password)) { + $security->handleFailedLogin($user, $email, (string) $ip); + + return response()->json([ + 'status' => false, + 'message' => 'Invalid email or password.', + ], 401); } - $jwtToken = JWTAuth::fromUser($user); - $sanctumToken = $user->createToken('api')->plainTextToken; - $roles = $user->roles() - ->where('roles.is_active', 1) - ->pluck('roles.name') - ->values() - ->all(); + $security->resetFailedAttempts($user); + $security->logSuccessfulLogin($user->fresh(), $request); - return $this->respondSuccess([ - 'user' => [ - 'id' => (int) $user->id, - 'firstname' => $user->firstname, - 'lastname' => $user->lastname, - 'email' => $user->email, - 'roles' => $roles, - ], - 'jwt' => [ - 'access_token' => $jwtToken, - 'token_type' => 'bearer', - 'expires_in' => (int) config('jwt.ttl') * 60, - ], - 'sanctum' => [ - 'access_token' => $sanctumToken, - 'token_type' => 'bearer', - ], - ], 'Authenticated.'); + $fresh = $user->fresh(); + if (!$fresh) { + return response()->json([ + 'status' => false, + 'message' => 'Invalid email or password.', + ], 401); + } + + $jwtToken = JWTAuth::fromUser($fresh); + + return response()->json($security->buildLoginResponse($fresh, $jwtToken)); } public function refresh(Request $request): JsonResponse diff --git a/app/Http/Controllers/Api/Auth/AuthSessionController.php b/app/Http/Controllers/Api/Auth/AuthSessionController.php new file mode 100644 index 00000000..0517af1e --- /dev/null +++ b/app/Http/Controllers/Api/Auth/AuthSessionController.php @@ -0,0 +1,257 @@ +sessionAuth->sanitizeRedirectTarget( + (string) ($request->query('redirect_to') ?? ''), + ); + + if (! Auth::guard('web')->check()) { + return response()->json([ + 'status' => true, + 'authenticated' => false, + 'redirect_to' => $redirectTo, + ]); + } + + /** @var list|null $roles */ + $roles = session('roles'); + $roles = is_array($roles) ? array_values(array_filter(array_map('strval', $roles))) : []; + $currentRole = session('role'); + + if ($roles !== [] && count($roles) > 1 && ! $currentRole) { + return response()->json([ + 'status' => true, + 'authenticated' => true, + 'next_url' => $this->urls->webSelectRoleUrl(false), + 'roles' => $roles, + 'pending_redirect' => session('post_login_redirect'), + ]); + } + + if ($redirectTo !== null) { + return response()->json([ + 'status' => true, + 'authenticated' => true, + 'next_url' => $redirectTo, + ]); + } + + $pick = $currentRole !== null && $currentRole !== '' + ? [(string) $currentRole] + : $roles; + + return response()->json([ + 'status' => true, + 'authenticated' => true, + 'next_url' => $this->sessionAuth->dashboardRouteForRoles($pick), + ]); + } + + /** + * CI `login` — POST /user/login. + */ + public function login(LoginSessionRequest $request): JsonResponse + { + $email = (string) $request->validated('email'); + $password = (string) $request->validated('password'); + $redirectRaw = $request->validated('redirect_to'); + $sanitizedRedirect = $redirectRaw !== null + ? $this->sessionAuth->sanitizeRedirectTarget((string) $redirectRaw) + : null; + + $ip = (string) $request->ip(); + + if ($this->security->isIpBlocked($ip)) { + return response()->json([ + 'status' => false, + 'message' => 'Too many failed attempts from your IP. Please try again later.', + ], 429); + } + + $user = User::query()->where('email', $email)->first(); + + if (! $user) { + $this->security->logIpAttempt($ip); + + return response()->json([ + 'status' => false, + 'message' => 'The email and password combination you entered is invalid. Please try again.', + ], 401); + } + + if ($user->is_suspended) { + return response()->json([ + 'status' => false, + 'message' => 'Account suspended. Please check your email to reset your password.', + ], 403); + } + + if (! ci_password_verify($password, (string) $user->password)) { + $this->security->handleFailedLogin($user, $email, $ip); + + return response()->json([ + 'status' => false, + 'message' => 'The email and password combination you entered is invalid. Please try again.', + ], 401); + } + + $fresh = $user->fresh(); + if (! $fresh) { + return response()->json([ + 'status' => false, + 'message' => 'The email and password combination you entered is invalid. Please try again.', + ], 401); + } + + $this->security->resetFailedAttempts($fresh); + $this->security->logSuccessfulLogin($fresh, $request); + + $dest = $this->sessionAuth->resolvePostLoginDestination($fresh, $sanitizedRedirect); + + if (($dest['kind'] ?? '') === 'no_roles') { + return response()->json([ + 'status' => false, + 'message' => (string) ($dest['reason'] ?? 'Role not assigned. Please contact support.'), + ], 403); + } + + return response()->json([ + 'status' => true, + 'requires_role_selection' => ($dest['kind'] ?? '') === 'select_role', + 'roles' => $dest['roles'] ?? null, + 'next_url' => $dest['redirect_url'] ?? $this->urls->docsHomeUrl(false), + ]); + } + + /** + * CI `logout` — GET /logout. + */ + public function logout(Request $request): JsonResponse + { + $userId = Auth::guard('web')->id(); + $email = Auth::guard('web')->user()?->email ?? session('user_email'); + + $this->sessionAuth->logLogout($userId ? (int) $userId : null, $email ? (string) $email : null); + + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return response()->json([ + 'status' => true, + 'next_url' => $this->urls->docsHomeUrl(false), + ]); + } + + /** + * CI `selectRole` — GET /select-role. + */ + public function selectRole(Request $request): JsonResponse + { + if (! Auth::guard('web')->check()) { + return response()->json([ + 'status' => false, + 'message' => 'No roles available.', + ], 401); + } + + /** @var list|null $roles */ + $roles = session('roles'); + $roles = is_array($roles) ? array_values(array_filter(array_map('strval', $roles))) : []; + + if ($roles === []) { + return response()->json([ + 'status' => false, + 'message' => 'No roles available.', + ], 422); + } + + return response()->json([ + 'status' => true, + 'roles' => $roles, + 'pending_redirect' => session('post_login_redirect'), + 'redirect_to_query' => $this->sessionAuth->sanitizeRedirectTarget( + (string) ($request->query('redirect_to') ?? ''), + ), + ]); + } + + /** + * CI `setRole` — POST /set-role. + */ + public function setRole(SetRoleSessionRequest $request): JsonResponse + { + if (! Auth::guard('web')->check()) { + return response()->json([ + 'status' => false, + 'message' => 'Unauthorized.', + ], 401); + } + + $selectedRole = (string) $request->validated('selected_role'); + + /** @var list|null $available */ + $available = session('roles'); + $available = is_array($available) ? array_values(array_filter(array_map('strval', $available))) : []; + + if ($available === [] || ! in_array($selectedRole, $available, true)) { + return response()->json([ + 'status' => false, + 'message' => 'Invalid role selected.', + ], 422); + } + + $redirectRaw = $request->validated('redirect_to'); + $redirectTo = $redirectRaw !== null + ? $this->sessionAuth->sanitizeRedirectTarget((string) $redirectRaw) + : null; + + if ($redirectTo === null) { + $redirectTo = $this->sessionAuth->sanitizeRedirectTarget( + (string) (session('post_login_redirect') ?? ''), + ); + } + + session(['role' => $selectedRole]); + session()->forget('post_login_redirect'); + + $next = $redirectTo ?? $this->sessionAuth->dashboardRouteForRoles([$selectedRole]); + + return response()->json([ + 'status' => true, + 'role' => $selectedRole, + 'next_url' => $next, + ]); + } +} diff --git a/app/Http/Controllers/Api/Auth/IpBanController.php b/app/Http/Controllers/Api/Auth/IpBanController.php index 59dcf7ae..3f45f8c8 100644 --- a/app/Http/Controllers/Api/Auth/IpBanController.php +++ b/app/Http/Controllers/Api/Auth/IpBanController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Auth; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Security\IpBanIndexRequest; use App\Http\Requests\Security\IpBanRequest; use App\Http\Requests\Security\IpUnbanRequest; diff --git a/app/Http/Controllers/Api/Auth/RegisterController.php b/app/Http/Controllers/Api/Auth/RegisterController.php index d64cfb91..d9bef5af 100644 --- a/app/Http/Controllers/Api/Auth/RegisterController.php +++ b/app/Http/Controllers/Api/Auth/RegisterController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers\Api\Auth; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Resources\Auth\RegisterResource; +use App\Services\Auth\CodeIgniterApiRegistrationService; use App\Services\Auth\RegistrationCaptchaService; use App\Services\Auth\RegistrationService; use Illuminate\Http\JsonResponse; @@ -31,6 +32,11 @@ class RegisterController extends BaseApiController public function store(Request $request): JsonResponse { + // CodeIgniter-style JSON register (POST api/v1/register): password + no captcha field. + if (! $request->has('captcha') && $request->filled('password')) { + return app(CodeIgniterApiRegistrationService::class)->registerResponse($request); + } + if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') { return response()->json([ 'ok' => true, diff --git a/app/Http/Controllers/Api/Auth/RolePermissionController.php b/app/Http/Controllers/Api/Auth/RolePermissionController.php new file mode 100644 index 00000000..3395caac --- /dev/null +++ b/app/Http/Controllers/Api/Auth/RolePermissionController.php @@ -0,0 +1,264 @@ +queryService->listRoles($request->validated()); + + return $this->success([ + 'roles' => RoleResource::collection($roles->items()), + 'meta' => [ + 'total' => $roles->total(), + 'per_page' => $roles->perPage(), + 'current_page' => $roles->currentPage(), + 'last_page' => $roles->lastPage(), + ], + ]); + } + + public function showRole(int $roleId): JsonResponse + { + $role = Role::query()->find($roleId); + if (!$role) { + return $this->error('Role not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success([ + 'role' => new RoleResource($role), + ]); + } + + public function storeRole(RoleStoreRequest $request): JsonResponse + { + try { + $role = $this->roleCrudService->create($request->validated()); + } catch (\Throwable $e) { + Log::error('Role store failed: ' . $e->getMessage()); + return $this->error('Failed to create role.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success([ + 'role' => new RoleResource($role), + ], 'Role created successfully.', Response::HTTP_CREATED); + } + + public function updateRole(RoleUpdateRequest $request, int $roleId): JsonResponse + { + $role = Role::query()->find($roleId); + if (!$role) { + return $this->error('Role not found.', Response::HTTP_NOT_FOUND); + } + + try { + $role = $this->roleCrudService->update($role, $request->validated()); + } catch (\Throwable $e) { + Log::error('Role update failed: ' . $e->getMessage()); + return $this->error('Failed to update role.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success([ + 'role' => new RoleResource($role), + ], 'Role updated successfully.'); + } + + public function deleteRole(int $roleId): JsonResponse + { + $role = Role::query()->find($roleId); + if (!$role) { + return $this->error('Role not found.', Response::HTTP_NOT_FOUND); + } + + try { + $this->roleCrudService->delete($role); + } catch (\Throwable $e) { + Log::error('Role delete failed: ' . $e->getMessage()); + return $this->error('Failed to delete role.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success(null, 'Role deleted successfully.'); + } + + public function users(UserRoleListRequest $request): JsonResponse + { + $users = $this->queryService->listUsersWithRoles($request->validated()); + + return $this->success([ + 'users' => UserWithRolesResource::collection($users->items()), + 'meta' => [ + 'total' => $users->total(), + 'per_page' => $users->perPage(), + 'current_page' => $users->currentPage(), + 'last_page' => $users->lastPage(), + ], + ]); + } + + public function assignRoles(AssignUserRolesRequest $request, int $userId): JsonResponse + { + $actorId = $this->authenticatedUserIdOrUnauthorized(); + if ($actorId instanceof JsonResponse) { + return $actorId; + } + + try { + $result = $this->assignmentService->assignRoles( + $userId, + (array) ($request->validated()['role_ids'] ?? []), + $actorId + ); + } catch (\Throwable $e) { + Log::error('Assign roles failed: ' . $e->getMessage()); + return $this->error('Failed to update roles.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + if (!($result['ok'] ?? false)) { + return $this->error($result['message'] ?? 'Failed to update roles.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success([ + 'role_names' => $result['role_names'] ?? [], + ], 'Roles updated successfully.'); + } + + public function permissions(): JsonResponse + { + $permissions = $this->permissionService->listPermissions(); + + return $this->success([ + 'permissions' => PermissionResource::collection($permissions), + ]); + } + + public function showPermission(int $permissionId): JsonResponse + { + $permission = Permission::query()->find($permissionId); + if (!$permission) { + return $this->error('Permission not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success([ + 'permission' => new PermissionResource($permission), + ]); + } + + public function storePermission(PermissionStoreRequest $request): JsonResponse + { + try { + $permission = $this->permissionCrudService->create($request->validated()); + } catch (\Throwable $e) { + Log::error('Permission store failed: ' . $e->getMessage()); + return $this->error('Failed to create permission.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success([ + 'permission' => new PermissionResource($permission), + ], 'Permission created successfully.', Response::HTTP_CREATED); + } + + public function updatePermission(PermissionUpdateRequest $request, int $permissionId): JsonResponse + { + $permission = Permission::query()->find($permissionId); + if (!$permission) { + return $this->error('Permission not found.', Response::HTTP_NOT_FOUND); + } + + try { + $permission = $this->permissionCrudService->update($permission, $request->validated()); + } catch (\Throwable $e) { + Log::error('Permission update failed: ' . $e->getMessage()); + return $this->error('Failed to update permission.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success([ + 'permission' => new PermissionResource($permission), + ], 'Permission updated successfully.'); + } + + public function deletePermission(int $permissionId): JsonResponse + { + $permission = Permission::query()->find($permissionId); + if (!$permission) { + return $this->error('Permission not found.', Response::HTTP_NOT_FOUND); + } + + try { + $this->permissionCrudService->delete($permission); + } catch (\Throwable $e) { + Log::error('Permission delete failed: ' . $e->getMessage()); + return $this->error('Failed to delete permission.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success(null, 'Permission deleted successfully.'); + } + + public function rolePermissions(int $roleId): JsonResponse + { + $rows = $this->permissionService->listRolePermissions($roleId); + + return $this->success([ + 'permissions' => RolePermissionResource::collection($rows), + ]); + } + + public function updateRolePermissions(RolePermissionUpdateRequest $request, int $roleId): JsonResponse + { + try { + $this->permissionService->saveRolePermissions($roleId, $request->validated()['permissions'] ?? []); + } catch (\Throwable $e) { + Log::error('Role permissions update failed: ' . $e->getMessage()); + return $this->error('Failed to update role permissions.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success(null, 'Permissions saved successfully.'); + } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } +} diff --git a/app/Http/Controllers/Api/Auth/RoleSwitcherController.php b/app/Http/Controllers/Api/Auth/RoleSwitcherController.php new file mode 100644 index 00000000..31d5a9b3 --- /dev/null +++ b/app/Http/Controllers/Api/Auth/RoleSwitcherController.php @@ -0,0 +1,63 @@ +authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $roles = $this->switchService->getUserRoleNames($guard); + if (empty($roles)) { + return $this->error('No roles assigned to this user.', Response::HTTP_NOT_FOUND); + } + + return $this->success([ + 'roles' => $roles, + ]); + } + + public function switch(RoleSwitchRequest $request): JsonResponse + { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $role = (string) $request->validated()['role']; + $route = $this->switchService->switchRole($role); + + return $this->success([ + 'role' => $role, + 'dashboard_route' => $route, + ], 'Role switched successfully.'); + } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Please log in first.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } +} diff --git a/app/Http/Controllers/Api/Auth/SessionTimeoutController.php b/app/Http/Controllers/Api/Auth/SessionTimeoutController.php new file mode 100644 index 00000000..8b5df4ae --- /dev/null +++ b/app/Http/Controllers/Api/Auth/SessionTimeoutController.php @@ -0,0 +1,102 @@ +json([ + 'success' => true, + 'timeout' => SessionTimeout::TIMEOUT_DURATION, + 'warning_time' => SessionTimeout::WARNING_THRESHOLD, + 'check_interval' => SessionTimeout::CHECK_INTERVAL, + 'logout_url' => $this->urls->webLogoutUrl(), + 'keep_alive_url' => $this->urls->webSessionPingUrl(), + 'check_url' => $this->urls->webSessionCheckTimeoutUrl(), + ]); + } + + public function checkTimeout(Request $request): JsonResponse + { + $session = $request->session(); + + if (! $session->has(self::SESSION_KEY)) { + return $this->expireSession($request); + } + + $lastActivity = (int) $session->get(self::SESSION_KEY); + $elapsed = time() - $lastActivity; + + if ($elapsed > SessionTimeout::TIMEOUT_DURATION) { + return $this->expireSession($request); + } + + if ($elapsed > SessionTimeout::WARNING_THRESHOLD) { + return response()->json([ + 'status' => 'warning', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed, + ]); + } + + return response()->json([ + 'status' => 'active', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed, + ]); + } + + public function pingActivity(Request $request): JsonResponse + { + $session = $request->session(); + + if (! $session->has(self::SESSION_KEY) + || (time() - (int) $session->get(self::SESSION_KEY)) > SessionTimeout::TIMEOUT_DURATION) { + return $this->expireSession($request); + } + + $session->put(self::SESSION_KEY, time()); + + return response()->json([ + 'status' => 'active', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION, + ]); + } + + private function expireSession(Request $request): JsonResponse + { + $request->session()->flash('error', 'Your session has expired due to inactivity.'); + $request->session()->forget(self::SESSION_KEY); + + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return response()->json([ + 'status' => 'expired', + 'redirect' => $this->urls->webLoginUrl(), + 'message' => 'Your session has expired due to inactivity.', + ]); + } +} diff --git a/app/Http/Controllers/Api/BadgeScan/BadgeScanController.php b/app/Http/Controllers/Api/BadgeScan/BadgeScanController.php new file mode 100644 index 00000000..99e7ec5a --- /dev/null +++ b/app/Http/Controllers/Api/BadgeScan/BadgeScanController.php @@ -0,0 +1,55 @@ +all(), [ + 'badge_scan' => ['required', 'string', 'max:255'], + ]); + + if ($validator->fails()) { + return $this->respondValidationError($validator->errors()->toArray()); + } + + $result = $this->badgeScan->processScan($validator->validated()['badge_scan']); + + if (! $result['recognized']) { + return $this->error($result['message'], Response::HTTP_NOT_FOUND); + } + + return $this->success([ + 'recognized' => true, + 'user_id' => $result['user_id'] ?? null, + 'display_name' => $result['display_name'] ?? null, + ], $result['message']); + } + + /** + * Authenticated scan log listing (legacy CI RFIDController::log). + */ + public function logs(): JsonResponse + { + return $this->success([ + 'logs' => $this->badgeScan->scanLogRows(), + ]); + } +} diff --git a/app/Http/Controllers/Api/Badges/BadgeController.php b/app/Http/Controllers/Api/Badges/BadgeController.php index 9c253950..e29ad842 100644 --- a/app/Http/Controllers/Api/Badges/BadgeController.php +++ b/app/Http/Controllers/Api/Badges/BadgeController.php @@ -22,14 +22,30 @@ class BadgeController extends Controller public function generatePdf(Request $request) { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $validator = Validator::make($request->all(), [ - 'user_ids' => ['required', 'array', 'min:1'], - 'user_ids.*' => ['integer', 'min:1'], + 'user_ids' => ['nullable', 'array'], + 'user_ids.*' => ['integer', 'exists:users,id'], + 'student_ids' => ['nullable', 'array'], + 'student_ids.*' => ['integer', 'exists:students,id'], 'school_year' => ['nullable', 'string', 'max:50'], 'roles' => ['nullable', 'array'], 'classes' => ['nullable', 'array'], ]); + $validator->after(function ($validator): void { + $data = $validator->getData(); + $users = array_values(array_filter((array) ($data['user_ids'] ?? []), static fn ($v) => (int) $v > 0)); + $students = array_values(array_filter((array) ($data['student_ids'] ?? []), static fn ($v) => (int) $v > 0)); + if ($users === [] && $students === []) { + $validator->errors()->add('user_ids', 'Provide at least one user id or student id.'); + } + }); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -40,11 +56,12 @@ class BadgeController extends Controller $data = $validator->validated(); return $this->badgePdfService->generate( + studentIds: $data['student_ids'] ?? [], userIds: $data['user_ids'] ?? [], schoolYear: $data['school_year'] ?? null, rolesMap: $data['roles'] ?? [], classesMap: $data['classes'] ?? [], - actorId: optional($request->user())->id + actorId: $guard ); } @@ -54,7 +71,7 @@ class BadgeController extends Controller return response()->json([ 'ok' => true, 'data' => $this->badgePrintLogService->getStatus( - $this->parseUserIds($request), + $this->parseCombinedBadgeIds($request), $request->input('school_year') ), ]); @@ -69,14 +86,30 @@ class BadgeController extends Controller public function logPrint(Request $request): JsonResponse { try { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $validator = Validator::make($request->all(), [ - 'user_ids' => ['required', 'array', 'min:1'], - 'user_ids.*' => ['integer', 'min:1'], + 'user_ids' => ['nullable', 'array'], + 'user_ids.*' => ['integer', 'exists:users,id'], + 'student_ids' => ['nullable', 'array'], + 'student_ids.*' => ['integer', 'exists:students,id'], 'school_year' => ['nullable', 'string', 'max:50'], 'roles' => ['nullable', 'array'], 'classes' => ['nullable', 'array'], ]); + $validator->after(function ($validator): void { + $data = $validator->getData(); + $users = array_values(array_filter((array) ($data['user_ids'] ?? []), static fn ($v) => (int) $v > 0)); + $students = array_values(array_filter((array) ($data['student_ids'] ?? []), static fn ($v) => (int) $v > 0)); + if ($users === [] && $students === []) { + $validator->errors()->add('user_ids', 'Provide at least one user id or student id.'); + } + }); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -87,8 +120,11 @@ class BadgeController extends Controller $data = $validator->validated(); $inserted = $this->badgePrintLogService->log( - userIds: $data['user_ids'] ?? [], - actorId: optional($request->user())->id, + userIds: array_values(array_unique(array_merge( + $data['user_ids'] ?? [], + $data['student_ids'] ?? [] + ))), + actorId: $guard, schoolYear: $data['school_year'] ?? null, rolesMap: $data['roles'] ?? [], classesMap: $data['classes'] ?? [] @@ -112,24 +148,75 @@ class BadgeController extends Controller 'ok' => true, 'data' => $this->badgeFormDataService->build( schoolYear: $request->input('school_year'), - selectedUserIds: $this->parseUserIds($request), + selectedUserIds: $this->parseStaffUserIds($request), activeRole: $request->input('active_role') ), ]); } - private function parseUserIds(Request $request): array + /** + * Staff user IDs for the badge picker (`users.id`). + * + * @return int[] + */ + private function parseStaffUserIds(Request $request): array { - $userIds = $request->input('user_ids', $request->query('user_ids', [])); + $ids = $request->input('user_ids', $request->query('user_ids', [])); - if (is_string($userIds)) { - $userIds = array_filter(array_map('trim', explode(',', $userIds)), 'strlen'); - } elseif (!is_array($userIds)) { - $userIds = $userIds ? [$userIds] : []; + if (is_string($ids)) { + $ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen'); + } elseif (!is_array($ids)) { + $ids = $ids ? [$ids] : []; } - $userIds = array_values(array_unique(array_map(static fn ($v) => (int) $v, $userIds))); + $ids = array_values(array_unique(array_map(static fn ($v) => (int) $v, $ids))); - return array_values(array_filter($userIds, static fn ($v) => $v > 0)); + return array_values(array_filter($ids, static fn ($v) => $v > 0)); + } + + /** + * Combined `users.id` and `students.id` for print-status (same numeric space as stored in logs). + * + * @return int[] + */ + private function parseCombinedBadgeIds(Request $request): array + { + return array_values(array_unique(array_merge( + $this->parseStaffUserIds($request), + $this->parseStudentIds($request) + ))); + } + + /** + * Student primary keys (`students.id`) from query or body. + * + * @return int[] + */ + private function parseStudentIds(Request $request): array + { + $ids = $request->input('student_ids', $request->query('student_ids', [])); + + if (is_string($ids)) { + $ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen'); + } elseif (!is_array($ids)) { + $ids = $ids ? [$ids] : []; + } + + $ids = array_values(array_unique(array_map(static fn ($v) => (int) $v, $ids))); + + return array_values(array_filter($ids, static fn ($v) => $v > 0)); + } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; } } diff --git a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php index ca98f592..86732288 100644 --- a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php +++ b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\ClassPreparation; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest; use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest; use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest; diff --git a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php index f92f6797..4680440b 100644 --- a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php +++ b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\ClassProgress; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\ClassProgress\ClassProgressIndexRequest; use App\Http\Requests\ClassProgress\ClassProgressMetaRequest; use App\Http\Requests\ClassProgress\ClassProgressStoreRequest; @@ -12,6 +12,7 @@ use App\Http\Resources\ClassProgress\ClassProgressReportResource; use App\Http\Resources\ClassProgress\ClassProgressShowResource; use App\Models\ClassProgressAttachment; use App\Models\ClassProgressReport; +use App\Models\User; use App\Services\ClassProgress\ClassProgressAttachmentService; use App\Services\ClassProgress\ClassProgressMetaService; use App\Services\ClassProgress\ClassProgressMutationService; @@ -19,6 +20,7 @@ use App\Services\ClassProgress\ClassProgressQueryService; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response; class ClassProgressController extends BaseApiController { @@ -40,7 +42,7 @@ class ClassProgressController extends BaseApiController $payload = [ 'subject_sections' => $this->metaService->subjectSections(), 'status_options' => $this->metaService->statusOptions(), - 'sunday_options' => $this->metaService->sundayOptions($sundayCount), + 'sunday_options' => $this->metaService->sundayOptionsAlignedWithCi($sundayCount), 'curriculum' => $this->metaService->curriculumOptions($classId ? (int) $classId : null), ]; @@ -49,8 +51,13 @@ class ClassProgressController extends BaseApiController public function index(ClassProgressIndexRequest $request): JsonResponse { + $auth = $this->requireAuthenticatedUser($request->user()); + if ($auth instanceof JsonResponse) { + return $auth; + } + $filters = $request->validated(); - $paginator = $this->queryService->listReports($request->user(), $filters); + $paginator = $this->queryService->listReports($auth, $filters); if (!empty($filters['group_by_week'])) { $grouped = ClassProgressGroupCollection::fromPaginator($paginator); @@ -70,10 +77,23 @@ class ClassProgressController extends BaseApiController public function store(ClassProgressStoreRequest $request): JsonResponse { + $auth = $this->requireAuthenticatedUser($request->user()); + if ($auth instanceof JsonResponse) { + return $auth; + } + try { $filesBySubject = $request->filesBySubject(); - $reports = $this->mutationService->createReports($request->user(), $request->validated(), $filesBySubject); + $reports = $this->mutationService->createReports($auth, $request->validated(), $filesBySubject); } catch (\InvalidArgumentException $e) { + if ($e->getMessage() === 'CONFIRM_OVERWRITE') { + return response()->json([ + 'status' => false, + 'message' => 'A progress report already exists for this week. Resubmit with confirm_overwrite=true to replace it.', + 'data' => ['requires_confirmation' => true], + ], 409); + } + return $this->respondError($e->getMessage(), 422); } catch (\Throwable $e) { Log::error('Failed to create class progress reports.', [ @@ -87,7 +107,12 @@ class ClassProgressController extends BaseApiController public function show(ClassProgressReport $class_progress): JsonResponse { - $weeklyReports = $this->queryService->weeklyReports($this->laravelRequest->user(), $class_progress); + $auth = $this->requireAuthenticatedUser($this->laravelRequest->user()); + if ($auth instanceof JsonResponse) { + return $auth; + } + + $weeklyReports = $this->queryService->weeklyReports($auth, $class_progress); return $this->respondSuccess( new ClassProgressShowResource([ @@ -164,4 +189,17 @@ class ClassProgressController extends BaseApiController return response()->download($path, basename($path)); } + + /** + * @param mixed $user + * @return User|JsonResponse + */ + private function requireAuthenticatedUser($user): User|JsonResponse + { + if (!$user instanceof User) { + return $this->respondError('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $user; + } } diff --git a/app/Http/Controllers/Api/Classes/ClassController.php b/app/Http/Controllers/Api/Classes/ClassController.php index e5f50d02..09991bc2 100644 --- a/app/Http/Controllers/Api/Classes/ClassController.php +++ b/app/Http/Controllers/Api/Classes/ClassController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Classes; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Classes\ClassSectionIndexRequest; use App\Http\Requests\Classes\ClassSectionStoreRequest; use App\Http\Requests\Classes\ClassSectionUpdateRequest; diff --git a/app/Http/Controllers/Api/Communication/CommunicationController.php b/app/Http/Controllers/Api/Communication/CommunicationController.php index 74300f85..e55655bc 100644 --- a/app/Http/Controllers/Api/Communication/CommunicationController.php +++ b/app/Http/Controllers/Api/Communication/CommunicationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Communication; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Services\Communication\CommunicationFamilyService; use App\Services\Communication\CommunicationPreviewService; use App\Services\Communication\CommunicationSendService; @@ -123,6 +123,11 @@ class CommunicationController extends BaseApiController ], 404); } + $senderId = $this->authenticatedUserIdOrUnauthorized(); + if ($senderId instanceof JsonResponse) { + return $senderId; + } + $result = $this->sendService->send([ 'student_id' => $studentId, 'family_id' => $familyId, @@ -133,7 +138,7 @@ class CommunicationController extends BaseApiController 'cc' => $cc, 'bcc' => $bcc, 'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'sent_by' => (int) (auth()->id() ?? 0), + 'sent_by' => $senderId, ]); if (!$result['ok']) { @@ -175,4 +180,17 @@ class CommunicationController extends BaseApiController } return 'Teacher'; } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php b/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php index 52b96100..09dc3cfa 100644 --- a/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php +++ b/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\CompetitionScores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Models\Competition; use App\Services\CompetitionScores\CompetitionScoresContextService; use App\Services\CompetitionScores\CompetitionScoresQueryService; @@ -33,8 +33,12 @@ class CompetitionScoresController extends BaseApiController public function index(Request $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - [$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + [$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard); $activeClassId = $this->contextService->resolveActiveClassId( $assignments, @@ -80,8 +84,12 @@ class CompetitionScoresController extends BaseApiController public function edit(Request $request, int $id): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - [$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + [$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard); $allowedClassIds = $this->contextService->getAllowedClassIds($assignments); if (empty($allowedClassIds)) { @@ -146,8 +154,12 @@ class CompetitionScoresController extends BaseApiController public function save(Request $request, int $id): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - [$assignments] = $this->contextService->getTeacherContext($userId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + [$assignments] = $this->contextService->getTeacherContext($guard); $allowedClassIds = $this->contextService->getAllowedClassIds($assignments); if (empty($allowedClassIds)) { @@ -209,4 +221,17 @@ class CompetitionScoresController extends BaseApiController 'savedCount' => count($cleanScores), ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Core/BaseApiController.php b/app/Http/Controllers/Api/Core/BaseApiController.php new file mode 100644 index 00000000..9f52b054 --- /dev/null +++ b/app/Http/Controllers/Api/Core/BaseApiController.php @@ -0,0 +1,276 @@ +laravelRequest = request(); + $this->request = new CiRequestAdapter($this->laravelRequest); + } + + protected function success($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse + { + return response()->json([ + 'status' => true, + 'message' => $message, + 'data' => $data, + ], $code); + } + + protected function error(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST, ?array $errors = null): JsonResponse + { + $payload = [ + 'status' => false, + 'message' => $message, + ]; + if (!empty($errors)) { + $payload['errors'] = $errors; + } + + return response()->json($payload, $code); + } + + protected function respondSuccess($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse + { + return $this->success($data, $message, $code); + } + + protected function respondCreated($data = null, string $message = 'Resource created successfully'): JsonResponse + { + return $this->success($data, $message, Response::HTTP_CREATED); + } + + protected function respondDeleted($data = null, string $message = 'Resource deleted successfully'): JsonResponse + { + return $this->success($data, $message, Response::HTTP_OK); + } + + protected function respondError(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST): JsonResponse + { + return $this->error($message, $code); + } + + protected function respondValidationError(array $errors, string $message = 'Validation failed'): JsonResponse + { + return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors); + } + + protected function validateRequest(array $data, array $rules): array + { + $normalized = []; + foreach ($rules as $field => $ruleSet) { + $normalized[$field] = $this->normalizeRules($ruleSet); + } + + $validator = Validator::make($data, $normalized); + $this->validator = $validator; + + if ($validator->fails()) { + return $validator->errors()->toArray(); + } + + return []; + } + + protected function validate(array $rules): bool + { + $payload = $this->payloadData(); + $errors = $this->validateRequest($payload, $rules); + return empty($errors); + } + + protected function payloadData(): array + { + $json = json_decode($this->laravelRequest->getContent() ?: '', true); + if (is_array($json)) { + return $json; + } + return array_merge($this->laravelRequest->query->all(), $this->laravelRequest->request->all()); + } + + protected function normalizeRules(array|string $ruleSet): array|string + { + if (is_array($ruleSet)) { + return $ruleSet; + } + $parts = explode('|', $ruleSet); + $result = []; + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + if (preg_match('/^max_length\\[(\\d+)\\]$/', $part, $m)) { + $result[] = 'max:' . $m[1]; + continue; + } + if (preg_match('/^min_length\\[(\\d+)\\]$/', $part, $m)) { + $result[] = 'min:' . $m[1]; + continue; + } + if (preg_match('/^in_list\\[(.+)\\]$/', $part, $m)) { + $result[] = 'in:' . $m[1]; + continue; + } + if (preg_match('/^greater_than_equal_to\\[(.+)\\]$/', $part, $m)) { + $result[] = 'gte:' . $m[1]; + continue; + } + if (preg_match('/^less_than_equal_to\\[(.+)\\]$/', $part, $m)) { + $result[] = 'lte:' . $m[1]; + continue; + } + if (preg_match('/^valid_date\\[(.+)\\]$/', $part, $m)) { + $result[] = 'date_format:' . $m[1]; + continue; + } + if ($part === 'valid_email') { + $result[] = 'email'; + continue; + } + if ($part === 'permit_empty') { + $result[] = 'nullable'; + continue; + } + if (preg_match('/^is_unique\\[([^\\.]+)\\.([^\\]]+)\\]$/', $part, $m)) { + $result[] = 'unique:' . $m[1] . ',' . $m[2]; + continue; + } + if ($part === 'string') { + $result[] = 'string'; + continue; + } + if ($part === 'integer') { + $result[] = 'integer'; + continue; + } + if ($part === 'numeric') { + $result[] = 'numeric'; + continue; + } + if ($part === 'required') { + $result[] = 'required'; + continue; + } + if ($part === 'alpha_numeric') { + $result[] = 'alpha_num'; + continue; + } + if ($part === 'in_array') { + $result[] = 'in_array'; + continue; + } + if ($part === 'valid_url') { + $result[] = 'url'; + continue; + } + if (strpos($part, 'max_size') === 0) { + $result[] = str_replace('max_size', 'max', $part); + continue; + } + $result[] = $part; + } + + return $result; + } + + protected function paginate($source, int $page = 1, int $perPage = 20): array + { + $page = max(1, $page); + $perPage = max(1, $perPage); + $offset = ($page - 1) * $perPage; + + if ($source instanceof EloquentBuilder || $source instanceof \Illuminate\Database\Query\Builder) { + $total = $source->toBase()->getCountForPagination(); + $items = $source->offset($offset)->limit($perPage)->get()->toArray(); + } elseif (is_array($source)) { + $total = count($source); + $items = array_slice($source, $offset, $perPage); + } else { + return [ + 'data' => [], + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => 0, + 'total_pages' => 0, + ], + ]; + } + + return [ + 'data' => array_values($items), + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => (int) ceil($total / $perPage), + ], + ]; + } + + protected function getCurrentUserId(): ?int + { + $userId = (int) (auth()->id() ?? 0); + return $userId > 0 ? $userId : null; + } + + protected function getCurrentUser(): ?object + { + $userId = $this->getCurrentUserId(); + if (!$userId) { + return null; + } + + $user = app(User::class); + $row = $user->find($userId); + if (!$row) { + return null; + } + + $name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')); + if ($name === '') { + $name = $row['email'] ?? 'User #' . $userId; + } + + try { + $roles = DB::table('user_roles ur') + ->select('LOWER(r.name) AS name') + ->join('roles r', 'r.id', '=', 'ur.role_id') + ->where('ur.user_id', $userId) + ->whereNull('ur.deleted_at') + ->pluck('name') + ->map(fn($name) => strtolower((string) $name)) + ->unique() + ->values() + ->toArray(); + } catch (\Throwable $e) { + Log::error('Unable to load user roles: ' . $e->getMessage()); + $roles = []; + } + + return (object) [ + 'id' => (int) $userId, + 'name' => $name, + 'email' => $row['email'] ?? null, + 'roles' => $roles, + ]; + } +} diff --git a/app/Http/Controllers/Api/Core/CiRequestAdapter.php b/app/Http/Controllers/Api/Core/CiRequestAdapter.php new file mode 100644 index 00000000..5955075a --- /dev/null +++ b/app/Http/Controllers/Api/Core/CiRequestAdapter.php @@ -0,0 +1,71 @@ +request->query->all(); + } + return $this->request->query->get($key, $default); + } + + public function getPost(string $key = null, $default = null) + { + if ($key === null) { + return $this->request->request->all(); + } + return $this->request->request->get($key, $default); + } + + public function getJSON(bool $assoc = false) + { + $content = $this->request->getContent(); + if ($content === '') { + return $assoc ? [] : null; + } + return json_decode($content, $assoc); + } + + public function getVar(string $key, $default = null) + { + return $this->request->input($key, $default); + } + + public function getHeader(string $key) + { + return $this->request->headers->get($key); + } + + public function getHeaderLine(string $key) + { + return $this->request->header($key); + } + + public function getFile(string $key) + { + return $this->request->file($key); + } + + public function getRawInput(): string + { + return $this->request->getContent(); + } + + public function all(): array + { + return array_merge( + $this->request->query->all(), + $this->request->request->all(), + $this->request->json()->all() + ); + } +} diff --git a/app/Http/Controllers/Api/Discounts/DiscountController.php b/app/Http/Controllers/Api/Discounts/DiscountController.php index d7885a8d..f1fb5183 100644 --- a/app/Http/Controllers/Api/Discounts/DiscountController.php +++ b/app/Http/Controllers/Api/Discounts/DiscountController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Discounts; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest; use App\Http\Requests\Discounts\StoreDiscountVoucherRequest; use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest; @@ -50,12 +50,17 @@ class DiscountController extends BaseApiController public function apply(ApplyDiscountVoucherRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $result = $this->applyService->applyVoucher( (int) $payload['voucher_id'], $payload['parent_ids'], (bool) ($payload['allow_additional'] ?? false), - (int) (auth()->id() ?? 0) + $guard ); return response()->json($result, $result['ok'] ? 200 : 422); @@ -104,4 +109,17 @@ class DiscountController extends BaseApiController 'ok' => true, ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Documentation/ApiDocsAdminController.php b/app/Http/Controllers/Api/Documentation/ApiDocsAdminController.php new file mode 100644 index 00000000..39278ef2 --- /dev/null +++ b/app/Http/Controllers/Api/Documentation/ApiDocsAdminController.php @@ -0,0 +1,32 @@ +json( + $this->apiDocs->bootstrap(Auth::user(), false), + ); + } + + public function public(): JsonResponse + { + return response()->json( + $this->apiDocs->bootstrap(null, true), + ); + } +} diff --git a/app/Http/Controllers/Api/Documentation/DocsCatalogController.php b/app/Http/Controllers/Api/Documentation/DocsCatalogController.php new file mode 100644 index 00000000..50bd9922 --- /dev/null +++ b/app/Http/Controllers/Api/Documentation/DocsCatalogController.php @@ -0,0 +1,61 @@ +expectsJson()) { + return redirect()->to((string) config('docs.client_url', url('/docs'))); + } + + return response()->json([ + 'status' => true, + 'data' => $this->catalog->indexPayload(), + ]); + } + + public function ui(): View + { + return view('docs.swagger'); + } + + public function swagger(): JsonResponse + { + return response()->json([ + 'status' => true, + 'data' => $this->catalog->swaggerSpecsPayload(), + ]); + } + + /** + * Merged OpenAPI spec (static openapi.json + live route merge). + */ + public function swaggerMergedJson(): JsonResponse + { + $path = resource_path('docs/openapi.json'); + abort_unless(is_file($path), 404); + + $spec = app(OpenApiRouteExporter::class)->exportFromFile($path); + + return response()->json($spec, 200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store, max-age=0', + ]); + } +} diff --git a/app/Http/Controllers/Api/Email/BroadcastEmailController.php b/app/Http/Controllers/Api/Email/BroadcastEmailController.php index 16fd3d93..797d579c 100644 --- a/app/Http/Controllers/Api/Email/BroadcastEmailController.php +++ b/app/Http/Controllers/Api/Email/BroadcastEmailController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Email; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Services\BroadcastEmail\BroadcastEmailComposerService; use App\Services\BroadcastEmail\BroadcastEmailDispatchService; use App\Services\BroadcastEmail\BroadcastEmailImageService; diff --git a/app/Http/Controllers/Api/Email/EmailController.php b/app/Http/Controllers/Api/Email/EmailController.php index 2918b803..696316a9 100644 --- a/app/Http/Controllers/Api/Email/EmailController.php +++ b/app/Http/Controllers/Api/Email/EmailController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Email; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Email\EmailSendRequest; use App\Http\Resources\Email\EmailSenderResource; use App\Services\Email\EmailAttachmentService; diff --git a/app/Http/Controllers/Api/Email/EmailExtractorController.php b/app/Http/Controllers/Api/Email/EmailExtractorController.php index d38adeca..67f7d2f2 100644 --- a/app/Http/Controllers/Api/Email/EmailExtractorController.php +++ b/app/Http/Controllers/Api/Email/EmailExtractorController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Email; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Email\EmailExtractorCompareRequest; use App\Http\Resources\Email\EmailExtractorCompareResource; use App\Http\Resources\Email\EmailExtractorEmailsResource; diff --git a/app/Http/Controllers/Api/Exams/ExamDraftController.php b/app/Http/Controllers/Api/Exams/ExamDraftController.php index b465226e..b434813c 100644 --- a/app/Http/Controllers/Api/Exams/ExamDraftController.php +++ b/app/Http/Controllers/Api/Exams/ExamDraftController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Exams; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Exams\ExamDraftAdminLegacyRequest; use App\Http\Requests\Exams\ExamDraftAdminReviewRequest; use App\Http\Requests\Exams\ExamDraftTeacherStoreRequest; @@ -22,12 +22,12 @@ class ExamDraftController extends BaseApiController public function teacherIndex(): JsonResponse { - $teacherId = (int) (auth()->id() ?? 0); - if ($teacherId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->teacherService->listForTeacher($teacherId); + $data = $this->teacherService->listForTeacher($guard); return response()->json([ 'ok' => true, @@ -43,12 +43,12 @@ class ExamDraftController extends BaseApiController public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse { - $teacherId = (int) (auth()->id() ?? 0); - if ($teacherId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $result = $this->teacherService->store($teacherId, $request->validated(), $request->file('draft_file')); + $result = $this->teacherService->store($guard, $request->validated(), $request->file('draft_file')); if (!$result['ok']) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); } @@ -79,12 +79,12 @@ class ExamDraftController extends BaseApiController public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse { - $adminId = (int) (auth()->id() ?? 0); - if ($adminId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $result = $this->adminService->uploadLegacy($adminId, $request->validated(), $request->file('old_exam_file')); + $result = $this->adminService->uploadLegacy($guard, $request->validated(), $request->file('old_exam_file')); if (!$result['ok']) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); } @@ -97,12 +97,12 @@ class ExamDraftController extends BaseApiController public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse { - $adminId = (int) (auth()->id() ?? 0); - if ($adminId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $result = $this->adminService->review($adminId, $request->validated(), $request->file('final_file')); + $result = $this->adminService->review($guard, $request->validated(), $request->file('final_file')); if (!$result['ok']) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); } @@ -112,4 +112,17 @@ class ExamDraftController extends BaseApiController 'draft' => new ExamDraftResource($result['draft']), ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Expenses/ExpenseController.php b/app/Http/Controllers/Api/Expenses/ExpenseController.php index 726f5cb7..a7440736 100644 --- a/app/Http/Controllers/Api/Expenses/ExpenseController.php +++ b/app/Http/Controllers/Api/Expenses/ExpenseController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Expenses; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Expenses\StoreExpenseRequest; use App\Http\Requests\Expenses\UpdateExpenseRequest; use App\Http\Requests\Expenses\UpdateExpenseStatusRequest; @@ -77,8 +77,12 @@ class ExpenseController extends BaseApiController public function store(StoreExpenseRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $userId = (int) (auth()->id() ?? 0); $receiptName = null; if ($request->hasFile('receipt')) { @@ -95,10 +99,10 @@ class ExpenseController extends BaseApiController 'retailor' => $payload['retailor'] ?? null, 'date_of_purchase' => $payload['date_of_purchase'], 'purchased_by' => (int) $payload['purchased_by'], - 'added_by' => $userId, + 'added_by' => $guard, 'status' => $isDonation ? 'approved' : 'pending', 'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null, - 'approved_by' => $isDonation ? $userId : null, + 'approved_by' => $isDonation ? $guard : null, 'school_year' => $payload['school_year'] ?? null, 'semester' => $payload['semester'] ?? null, ]); @@ -111,13 +115,17 @@ class ExpenseController extends BaseApiController public function update(UpdateExpenseRequest $request, int $id): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $expense = Expense::query()->find($id); if (!$expense) { return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); } $payload = $request->validated(); - $userId = (int) (auth()->id() ?? 0); $receiptName = $expense->receipt_path; if ($request->hasFile('receipt')) { @@ -138,13 +146,13 @@ class ExpenseController extends BaseApiController 'date_of_purchase' => $payload['date_of_purchase'], 'purchased_by' => (int) $payload['purchased_by'], 'receipt_path' => $receiptName, - 'updated_by' => $userId, + 'updated_by' => $guard, ]; if ($isDonation) { $updateData['status'] = 'approved'; $updateData['status_reason'] = 'Marked as Donation (non-reimbursable).'; - $updateData['approved_by'] = $userId ?: null; + $updateData['approved_by'] = $guard ?: null; $updateData['reimbursement_id'] = null; } elseif (($expense->category ?? '') === 'Donation') { $updateData['status_reason'] = null; @@ -162,19 +170,36 @@ class ExpenseController extends BaseApiController public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $expense = Expense::query()->find($id); if (!$expense) { return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); } $payload = $request->validated(); - $userId = (int) (auth()->id() ?? 0); - $updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $userId); + $updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $guard); return response()->json([ 'ok' => true, 'expense' => new ExpenseResource($updated->toArray()), ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php b/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php index a9126454..fac7d96d 100644 --- a/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php +++ b/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\ExtraCharges; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest; use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest; use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource; @@ -87,8 +87,13 @@ class ExtraChargesController extends BaseApiController public function store(StoreExtraChargeRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); - $payload['created_by'] = (int) (auth()->id() ?? 0); + $payload['created_by'] = $userId; $result = $this->chargeService->createCharge($payload); @@ -102,8 +107,13 @@ class ExtraChargesController extends BaseApiController return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); } + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); - $payload['updated_by'] = (int) (auth()->id() ?? 0); + $payload['updated_by'] = $userId; $ok = $this->chargeService->updateCharge($charge, $payload); @@ -140,4 +150,17 @@ class ExtraChargesController extends BaseApiController 'ok' => $ok, ], $ok ? 200 : 422); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Family/FamilyAdminController.php b/app/Http/Controllers/Api/Family/FamilyAdminController.php index 54d1d606..02d2007c 100644 --- a/app/Http/Controllers/Api/Family/FamilyAdminController.php +++ b/app/Http/Controllers/Api/Family/FamilyAdminController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Family; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Families\FamilyAdminCardRequest; use App\Http\Requests\Families\FamilyAdminIndexRequest; use App\Http\Requests\Families\FamilyAdminSearchRequest; diff --git a/app/Http/Controllers/Api/Family/FamilyController.php b/app/Http/Controllers/Api/Family/FamilyController.php index ac45d7c9..63f3c6ad 100644 --- a/app/Http/Controllers/Api/Family/FamilyController.php +++ b/app/Http/Controllers/Api/Family/FamilyController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Family; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Families\FamiliesByStudentRequest; use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest; use App\Http\Requests\Families\FamilyAttachSecondByUserRequest; diff --git a/app/Http/Controllers/Api/Finance/FeeCalculationController.php b/app/Http/Controllers/Api/Finance/FeeCalculationController.php index 8aa1263d..aa2d24cc 100644 --- a/app/Http/Controllers/Api/Finance/FeeCalculationController.php +++ b/app/Http/Controllers/Api/Finance/FeeCalculationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Finance\FeeRefundRequest; use App\Http\Requests\Finance\FeeTuitionTotalRequest; use App\Http\Resources\Fees\FeeRefundResource; diff --git a/app/Http/Controllers/Api/Finance/FinancialController.php b/app/Http/Controllers/Api/Finance/FinancialController.php index 45d84525..6548199c 100644 --- a/app/Http/Controllers/Api/Finance/FinancialController.php +++ b/app/Http/Controllers/Api/Finance/FinancialController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Finance\FinancialReportRequest; use App\Http\Requests\Finance\FinancialSummaryRequest; use App\Http\Requests\Finance\FinancialUnpaidParentsRequest; diff --git a/app/Http/Controllers/Api/Finance/InvoiceController.php b/app/Http/Controllers/Api/Finance/InvoiceController.php index be40d695..348aff79 100644 --- a/app/Http/Controllers/Api/Finance/InvoiceController.php +++ b/app/Http/Controllers/Api/Finance/InvoiceController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Invoices\InvoiceManagementRequest; use App\Http\Requests\Invoices\InvoiceParentRequest; use App\Http\Requests\Invoices\InvoiceStatusRequest; @@ -88,9 +88,9 @@ class InvoiceController extends BaseApiController public function parentPayment(InvoiceParentRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $parentId = $this->authenticatedUserIdOrUnauthorized(); + if ($parentId instanceof JsonResponse) { + return $parentId; } $schoolYear = $request->validated()['school_year'] ?? null; @@ -136,4 +136,17 @@ class InvoiceController extends BaseApiController echo $pdfBytes; }, 'invoice_' . $invoiceId . '.pdf', ['Content-Type' => 'application/pdf']); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Finance/PaymentController.php b/app/Http/Controllers/Api/Finance/PaymentController.php index 23008afb..db5c5477 100644 --- a/app/Http/Controllers/Api/Finance/PaymentController.php +++ b/app/Http/Controllers/Api/Finance/PaymentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Payments\PaymentByParentRequest; use App\Http\Resources\Payments\PaymentResource; use App\Services\Payments\PaymentBalanceService; diff --git a/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php b/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php index eef854e7..5eeff71e 100644 --- a/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php +++ b/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Payments\PaymentEventChargesListRequest; use App\Services\Payments\PaymentEventChargesService; use Illuminate\Http\JsonResponse; @@ -26,6 +26,11 @@ class PaymentEventChargesController extends BaseApiController public function store(Request $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'parent_id' => ['required', 'integer', 'min:1'], @@ -47,9 +52,8 @@ class PaymentEventChargesController extends BaseApiController } $payload = $validator->validated(); - $userId = (int) (auth()->id() ?? 0); - $count = $this->service->addCharges($payload, $userId); + $count = $this->service->addCharges($payload, $guard); if ($count === 0) { return response()->json(['ok' => false, 'message' => 'No student selected.'], 422); } @@ -64,4 +68,17 @@ class PaymentEventChargesController extends BaseApiController return response()->json(['ok' => true, 'students' => $students]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Finance/PaymentManualController.php b/app/Http/Controllers/Api/Finance/PaymentManualController.php index a25a7c7c..d8969126 100644 --- a/app/Http/Controllers/Api/Finance/PaymentManualController.php +++ b/app/Http/Controllers/Api/Finance/PaymentManualController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Payments\PaymentManualEditRequest; use App\Http\Requests\Payments\PaymentManualUpdateRequest; use App\Services\Payments\PaymentManualService; @@ -63,6 +63,11 @@ class PaymentManualController extends BaseApiController public function record(PaymentManualUpdateRequest $request, int $invoiceId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $result = $this->service->recordPayment( @@ -75,7 +80,7 @@ class PaymentManualController extends BaseApiController $request->file('payment_file'), $payload['school_year'] ?? null, $payload['semester'] ?? null, - (int) (auth()->id() ?? 0) + $guard ); return response()->json(['ok' => true] + $result); @@ -83,6 +88,11 @@ class PaymentManualController extends BaseApiController public function edit(PaymentManualEditRequest $request, int $paymentId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $this->service->editPayment( @@ -91,7 +101,7 @@ class PaymentManualController extends BaseApiController (string) $payload['payment_method'], $payload['check_number'] ?? null, $request->file('payment_file'), - (int) (auth()->id() ?? 0) + $guard ); return response()->json(['ok' => true]); @@ -102,4 +112,17 @@ class PaymentManualController extends BaseApiController $mode = (string) ($request->query('mode') ?? 'download'); return $this->service->serveCheckFile($filename, $mode); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Finance/PaymentNotificationController.php b/app/Http/Controllers/Api/Finance/PaymentNotificationController.php index 206f97dc..4f2f9235 100644 --- a/app/Http/Controllers/Api/Finance/PaymentNotificationController.php +++ b/app/Http/Controllers/Api/Finance/PaymentNotificationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Payments\PaymentNotificationListRequest; use App\Http\Requests\Payments\PaymentNotificationSendRequest; use App\Http\Resources\Payments\PaymentNotificationLogResource; diff --git a/app/Http/Controllers/Api/Finance/PaymentTransactionController.php b/app/Http/Controllers/Api/Finance/PaymentTransactionController.php index cfec0534..f52645a1 100644 --- a/app/Http/Controllers/Api/Finance/PaymentTransactionController.php +++ b/app/Http/Controllers/Api/Finance/PaymentTransactionController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Resources\Payments\PaymentTransactionResource; use App\Services\Payments\PaymentTransactionService; use Illuminate\Http\JsonResponse; diff --git a/app/Http/Controllers/Api/Finance/PaypalPaymentController.php b/app/Http/Controllers/Api/Finance/PaypalPaymentController.php index f9a93879..1088860b 100644 --- a/app/Http/Controllers/Api/Finance/PaypalPaymentController.php +++ b/app/Http/Controllers/Api/Finance/PaypalPaymentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Payments\PaypalExecuteRequest; use App\Services\Payments\PaypalPaymentService; use Illuminate\Http\JsonResponse; diff --git a/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php b/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php index 46764bbf..d6a75e3b 100644 --- a/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php +++ b/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Payments\PaypalTransactionsListRequest; use App\Http\Resources\Payments\PaypalTransactionResource; use App\Services\Payments\PaypalTransactionsService; diff --git a/app/Http/Controllers/Api/Finance/PurchaseOrderController.php b/app/Http/Controllers/Api/Finance/PurchaseOrderController.php index ea9ccf4a..7be4dd29 100644 --- a/app/Http/Controllers/Api/Finance/PurchaseOrderController.php +++ b/app/Http/Controllers/Api/Finance/PurchaseOrderController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\PurchaseOrders\PurchaseOrderIndexRequest; use App\Http\Resources\PurchaseOrders\PurchaseOrderItemResource; use App\Http\Resources\PurchaseOrders\PurchaseOrderResource; @@ -99,6 +99,10 @@ class PurchaseOrderController extends BaseApiController } } + if (auth()->user() === null) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + try { $po = $this->createService->create($payload); } catch (RuntimeException $e) { @@ -137,7 +141,11 @@ class PurchaseOrderController extends BaseApiController } $user = auth()->user(); - $issuedBy = $user ? (string) ($user->email ?? $user->username ?? $user->id) : 'system'; + if ($user === null) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $issuedBy = (string) ($user->email ?? $user->username ?? $user->id); try { $result = $this->receiveService->receive($id, $payload['items'], $issuedBy); @@ -156,6 +164,10 @@ class PurchaseOrderController extends BaseApiController public function cancel(int $id): JsonResponse { + if (auth()->user() === null) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + $po = PurchaseOrder::query()->find($id); if (!$po) { return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); diff --git a/app/Http/Controllers/Api/Finance/RefundController.php b/app/Http/Controllers/Api/Finance/RefundController.php index 4334e904..6c503880 100644 --- a/app/Http/Controllers/Api/Finance/RefundController.php +++ b/app/Http/Controllers/Api/Finance/RefundController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Refunds\RefundDecisionRequest; use App\Http\Requests\Refunds\RefundIndexRequest; use App\Http\Requests\Refunds\RefundPaymentRequest; @@ -60,11 +60,15 @@ class RefundController extends BaseApiController public function store(RefundStoreRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); try { - $result = $this->requestService->requestRefund($payload, $actorId); + $result = $this->requestService->requestRefund($payload, $guard); } catch (\Throwable $e) { Log::error('Refund request failed.', ['error' => $e->getMessage()]); return response()->json(['ok' => false, 'message' => 'Failed to submit refund request.'], 500); @@ -88,14 +92,18 @@ class RefundController extends BaseApiController public function update(RefundDecisionRequest $request, int $refundId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); $result = $this->decisionService->updateDecision( $refundId, (string) $payload['status'], (string) $payload['reason'], - $actorId + $guard ); if (empty($result['ok'])) { @@ -107,8 +115,12 @@ class RefundController extends BaseApiController public function destroy(int $refundId): JsonResponse { - $actorId = (int) (auth()->id() ?? 0); - $result = $this->decisionService->cancel($refundId, $actorId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->decisionService->cancel($refundId, $guard); if (empty($result['ok'])) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel refund.'], 404); @@ -119,8 +131,12 @@ class RefundController extends BaseApiController public function approve(int $refundId): JsonResponse { - $actorId = (int) (auth()->id() ?? 0); - $result = $this->decisionService->approve($refundId, $actorId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->decisionService->approve($refundId, $guard); if (empty($result['ok'])) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Approve failed.'], 422); @@ -131,9 +147,13 @@ class RefundController extends BaseApiController public function reject(RefundRejectRequest $request, int $refundId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); - $result = $this->decisionService->reject($refundId, (string) $payload['reason'], $actorId); + $result = $this->decisionService->reject($refundId, (string) $payload['reason'], $guard); if (empty($result['ok'])) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Reject failed.'], 422); @@ -144,11 +164,15 @@ class RefundController extends BaseApiController public function recordPayment(RefundPaymentRequest $request, int $refundId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $payload['check_file'] = $request->file('check_file'); - $actorId = (int) (auth()->id() ?? 0); - $result = $this->payoutService->recordPayment($refundId, $payload, $actorId); + $result = $this->payoutService->recordPayment($refundId, $payload, $guard); if (empty($result['ok'])) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update payment.'], 422); } @@ -158,10 +182,14 @@ class RefundController extends BaseApiController public function recalculateOverpayments(RefundRecalculateRequest $request): JsonResponse { - $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } - $result = $this->overpaymentService->recalculate(true, $payload['invoice_number'] ?? null, $actorId); + $payload = $request->validated(); + + $result = $this->overpaymentService->recalculate(true, $payload['invoice_number'] ?? null, $guard); return response()->json([ 'ok' => true, @@ -183,4 +211,17 @@ class RefundController extends BaseApiController 'summary' => $summary, ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Finance/ReimbursementController.php b/app/Http/Controllers/Api/Finance/ReimbursementController.php index 8504cead..d6d34ffe 100644 --- a/app/Http/Controllers/Api/Finance/ReimbursementController.php +++ b/app/Http/Controllers/Api/Finance/ReimbursementController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Finance; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Files\FileNameRequest; use App\Http\Requests\Reimbursements\ReimbursementBatchAdminFileRequest; use App\Http\Requests\Reimbursements\ReimbursementBatchAssignmentRequest; @@ -68,6 +68,11 @@ class ReimbursementController extends BaseApiController public function markDonation(Request $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'expense_id' => ['required', 'integer', 'min:1'], @@ -81,10 +86,9 @@ class ReimbursementController extends BaseApiController } $payload = $validator->validated(); - $userId = (int) (auth()->id() ?? 0); try { - $this->donationService->markDonation((int) $payload['expense_id'], $userId); + $this->donationService->markDonation((int) $payload['expense_id'], $guard); } catch (RuntimeException $e) { $message = $e->getMessage(); $status = str_contains($message, 'not found') ? 404 : (str_contains($message, 'already') ? 409 : 422); @@ -98,6 +102,11 @@ class ReimbursementController extends BaseApiController public function createBatch(Request $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'title' => ['nullable', 'string', 'max:255'], @@ -111,11 +120,10 @@ class ReimbursementController extends BaseApiController } $payload = $validator->validated(); - $userId = (int) (auth()->id() ?? 0); $schoolYear = $this->context->getSchoolYear(); $semester = $this->context->getSemester(); - $batch = $this->batchService->createBatch($payload['title'] ?? null, $userId, $schoolYear, $semester); + $batch = $this->batchService->createBatch($payload['title'] ?? null, $guard, $schoolYear, $semester); return response()->json([ 'ok' => true, @@ -158,13 +166,17 @@ class ReimbursementController extends BaseApiController public function lockBatch(ReimbursementBatchLockRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $userId = (int) (auth()->id() ?? 0); try { $this->batchService->lockBatch( (int) $payload['batch_id'], - $userId, + $guard, $this->context->getSchoolYear(), $this->context->getSemester() ); @@ -181,6 +193,11 @@ class ReimbursementController extends BaseApiController public function uploadBatchAdminFile(ReimbursementBatchAdminFileRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $batchId = (int) $payload['batch_id']; $adminId = (int) $payload['admin_id']; @@ -194,7 +211,7 @@ class ReimbursementController extends BaseApiController } try { - $file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), (int) (auth()->id() ?? 0)); + $file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), $guard); } catch (\Throwable $e) { return response()->json(['ok' => false, 'message' => 'Unable to store the uploaded file.'], 500); } @@ -312,6 +329,11 @@ class ReimbursementController extends BaseApiController public function store(Request $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'amount' => ['required', 'numeric', 'gt:0'], @@ -333,7 +355,6 @@ class ReimbursementController extends BaseApiController } $payload = $validator->validated(); - $userId = (int) (auth()->id() ?? 0); $receiptName = null; if ($request->hasFile('receipt')) { @@ -343,7 +364,7 @@ class ReimbursementController extends BaseApiController try { $reimb = $this->crudService->store( $payload, - $userId, + $guard, $this->context->getSchoolYear(), $this->context->getSemester(), $receiptName @@ -363,8 +384,12 @@ class ReimbursementController extends BaseApiController public function process(ReimbursementProcessRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $userId = (int) (auth()->id() ?? 0); $receiptName = null; if ($request->hasFile('receipt')) { @@ -374,7 +399,7 @@ class ReimbursementController extends BaseApiController try { $reimb = $this->crudService->process( $payload, - $userId, + $guard, $this->context->getSchoolYear(), $this->context->getSemester(), $receiptName @@ -404,6 +429,11 @@ class ReimbursementController extends BaseApiController public function update(Request $request, int $id): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $reimb = Reimbursement::query()->find($id); if (!$reimb) { return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404); @@ -428,7 +458,6 @@ class ReimbursementController extends BaseApiController } $payload = $validator->validated(); - $userId = (int) (auth()->id() ?? 0); $receiptName = $reimb->receipt_path; if ($request->hasFile('receipt')) { @@ -438,7 +467,7 @@ class ReimbursementController extends BaseApiController $receiptName = null; } - $updated = $this->crudService->update($reimb, $payload, $userId, $receiptName); + $updated = $this->crudService->update($reimb, $payload, $guard, $receiptName); $data = $updated->toArray(); $data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null); @@ -447,4 +476,17 @@ class ReimbursementController extends BaseApiController 'reimbursement' => new ReimbursementResource($data), ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Frontend/FrontendController.php b/app/Http/Controllers/Api/Frontend/FrontendController.php index e1ce8cb7..95664035 100644 --- a/app/Http/Controllers/Api/Frontend/FrontendController.php +++ b/app/Http/Controllers/Api/Frontend/FrontendController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Frontend; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Resources\Frontend\FrontendPageResource; use App\Http\Resources\Frontend\FrontendUserResource; use App\Services\Frontend\FrontendPageService; diff --git a/app/Http/Controllers/Api/Frontend/InfoIconController.php b/app/Http/Controllers/Api/Frontend/InfoIconController.php index 45acf53a..150dcdda 100644 --- a/app/Http/Controllers/Api/Frontend/InfoIconController.php +++ b/app/Http/Controllers/Api/Frontend/InfoIconController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Frontend; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Resources\Frontend\ProfileIconResource; use App\Services\Frontend\ProfileIconService; use Illuminate\Http\JsonResponse; @@ -17,15 +17,28 @@ class InfoIconController extends BaseApiController public function profileIcon(): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $payload = $this->service->buildForUser($userId); + $payload = $this->service->buildForUser($guard); return $this->success([ 'profile' => new ProfileIconResource($payload), ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Frontend/LandingPageController.php b/app/Http/Controllers/Api/Frontend/LandingPageController.php index 121d3822..cc792f02 100644 --- a/app/Http/Controllers/Api/Frontend/LandingPageController.php +++ b/app/Http/Controllers/Api/Frontend/LandingPageController.php @@ -2,11 +2,12 @@ namespace App\Http\Controllers\Api\Frontend; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Frontend\LandingTeacherRequest; use App\Http\Resources\Frontend\LandingPageResource; use App\Services\Frontend\LandingPageService; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\Response; class LandingPageController extends BaseApiController { @@ -17,8 +18,13 @@ class LandingPageController extends BaseApiController public function index(LandingTeacherRequest $request): JsonResponse { + $user = $request->user(); + if (!$user) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + $payload = $this->service->dashboardForUser( - $request->user(), + $user, $request->validated()['class_section_id'] ?? null ); @@ -29,8 +35,13 @@ class LandingPageController extends BaseApiController public function teacher(LandingTeacherRequest $request): JsonResponse { + $user = $request->user(); + if (!$user) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + $payload = $this->service->teacher( - $request->user(), + $user, $request->validated()['class_section_id'] ?? null ); @@ -41,7 +52,12 @@ class LandingPageController extends BaseApiController public function parent(): JsonResponse { - $payload = $this->service->parent(auth()->user()); + $user = auth()->user(); + if (!$user) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $payload = $this->service->parent($user); return $this->success([ 'landing' => new LandingPageResource($payload), diff --git a/app/Http/Controllers/Api/Frontend/PageController.php b/app/Http/Controllers/Api/Frontend/PageController.php index 86cf6a2c..0aefcccf 100644 --- a/app/Http/Controllers/Api/Frontend/PageController.php +++ b/app/Http/Controllers/Api/Frontend/PageController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Frontend; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Frontend\ContactSubmitRequest; use App\Http\Resources\Frontend\ContactSubmissionResource; use App\Http\Resources\Frontend\PageContentResource; diff --git a/app/Http/Controllers/Api/Grading/GradingController.php b/app/Http/Controllers/Api/Grading/GradingController.php index 11516b36..4202936e 100644 --- a/app/Http/Controllers/Api/Grading/GradingController.php +++ b/app/Http/Controllers/Api/Grading/GradingController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Grading; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Grading\BelowSixtyEmailRequest; use App\Http\Requests\Grading\BelowSixtyMeetingRequest; use App\Http\Requests\Grading\BelowSixtyMeetingSaveRequest; @@ -79,20 +79,30 @@ class GradingController extends BaseApiController public function update(GradingScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); - $this->scoreService->update($payload, (int) (auth()->id() ?? 0)); + $this->scoreService->update($payload, $userId); return response()->json(['ok' => true]); } public function toggleLock(GradingLockRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $locked = $this->lockService->toggle( (int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true, 'locked' => $locked]); @@ -100,11 +110,16 @@ class GradingController extends BaseApiController public function lockAll(GradingLockAllRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->lockService->lockAll( (string) $payload['semester'], (string) $payload['school_year'], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true, 'locked_count' => $count]); @@ -137,12 +152,17 @@ class GradingController extends BaseApiController public function updatePlacementLevel(PlacementLevelRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $this->placementService->updatePlacementLevel( (int) $payload['student_id'], (string) $payload['school_year'], $payload['placement_level'] ?? null, - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true]); @@ -150,12 +170,17 @@ class GradingController extends BaseApiController public function updatePlacementLevels(PlacementLevelsRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $this->placementService->updatePlacementLevels( (int) $payload['class_section_id'], (string) $payload['school_year'], $payload['placement_level'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true]); @@ -163,12 +188,17 @@ class GradingController extends BaseApiController public function createPlacementBatch(PlacementBatchRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $batchId = $this->placementService->createPlacementBatch( (string) $payload['school_year'], (string) $payload['placement_test'], $payload['placement_level'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true, 'batch_id' => $batchId]); @@ -183,12 +213,17 @@ class GradingController extends BaseApiController public function updatePlacementBatch(PlacementBatchUpdateRequest $request, int $batchId): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $this->placementService->updatePlacementBatch( $batchId, (string) $payload['school_year'], $payload['placement_level'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true]); @@ -246,6 +281,11 @@ class GradingController extends BaseApiController public function updateBelowSixtyStatus(BelowSixtyStatusRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $this->belowSixtyService->updateStatus( (int) $payload['student_id'], @@ -253,7 +293,7 @@ class GradingController extends BaseApiController (string) $payload['school_year'], (string) $payload['status'], (string) ($payload['note'] ?? ''), - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true]); @@ -273,9 +313,27 @@ class GradingController extends BaseApiController public function saveBelowSixtyMeeting(BelowSixtyMeetingSaveRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); - $this->belowSixtyService->saveMeeting($payload, (int) (auth()->id() ?? 0)); + $this->belowSixtyService->saveMeeting($payload, $userId); return response()->json(['ok' => true]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php b/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php index 17f873e6..c16f2145 100644 --- a/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php +++ b/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Grading; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Grading\HomeworkTrackingRequest; use App\Http\Resources\Grading\HomeworkTrackingTeacherResource; use App\Services\Grading\HomeworkTrackingService; diff --git a/app/Http/Controllers/Api/Incidents/IncidentController.php b/app/Http/Controllers/Api/Incidents/IncidentController.php index 54336162..db8be062 100644 --- a/app/Http/Controllers/Api/Incidents/IncidentController.php +++ b/app/Http/Controllers/Api/Incidents/IncidentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Incidents; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Incidents\IncidentCancelRequest; use App\Http\Requests\Incidents\IncidentCloseRequest; use App\Http\Requests\Incidents\IncidentListRequest; @@ -86,6 +86,11 @@ class IncidentController extends BaseApiController public function store(Request $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'student_id' => ['required', 'integer', 'min:1'], @@ -104,7 +109,7 @@ class IncidentController extends BaseApiController } $payload = $validator->validated(); - $result = $this->currentService->addIncident($payload, (int) (auth()->id() ?? 0)); + $result = $this->currentService->addIncident($payload, $guard); if (empty($result['ok'])) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to add incident.'], 422); @@ -129,6 +134,11 @@ class IncidentController extends BaseApiController public function close(Request $request, int $incidentId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'state_description' => ['required', 'string', 'max:2000'], @@ -147,7 +157,7 @@ class IncidentController extends BaseApiController $incidentId, (string) $payload['state_description'], $payload['action_taken'] ?? null, - (int) (auth()->id() ?? 0) + $guard ); if (empty($result['ok'])) { @@ -162,12 +172,17 @@ class IncidentController extends BaseApiController public function cancel(IncidentCancelRequest $request, int $incidentId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $result = $this->currentService->cancelIncident( $incidentId, $payload['state_description'] ?? null, $payload['action_taken'] ?? null, - (int) (auth()->id() ?? 0) + $guard ); if (empty($result['ok'])) { @@ -179,4 +194,17 @@ class IncidentController extends BaseApiController 'incident_id' => $result['incident_id'], ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php b/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php index 63ed63ff..39501c5c 100644 --- a/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php +++ b/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Inventory; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Inventory\InventoryCategoryIndexRequest; use App\Http\Requests\Inventory\InventoryCategoryStoreRequest; use App\Http\Requests\Inventory\InventoryCategoryUpdateRequest; diff --git a/app/Http/Controllers/Api/Inventory/InventoryController.php b/app/Http/Controllers/Api/Inventory/InventoryController.php index e504cd13..669ea5dd 100644 --- a/app/Http/Controllers/Api/Inventory/InventoryController.php +++ b/app/Http/Controllers/Api/Inventory/InventoryController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Inventory; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Inventory\InventoryAdjustRequest; use App\Http\Requests\Inventory\InventoryAuditRequest; use App\Http\Requests\Inventory\InventoryItemIndexRequest; @@ -57,7 +57,12 @@ class InventoryController extends BaseApiController public function store(InventoryItemStoreRequest $request): JsonResponse { - $result = $this->items->create($request->validated(), (int) auth()->id()); + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $result = $this->items->create($request->validated(), $actor); if (!($result['ok'] ?? false)) { return $this->error($result['message'] ?? 'Failed to create item.', Response::HTTP_UNPROCESSABLE_ENTITY); } @@ -67,7 +72,12 @@ class InventoryController extends BaseApiController public function update(InventoryItemUpdateRequest $request, int $id): JsonResponse { - $result = $this->items->update($id, $request->validated(), (int) auth()->id()); + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $result = $this->items->update($id, $request->validated(), $actor); if (!($result['ok'] ?? false)) { return $this->error($result['message'] ?? 'Failed to update item.', Response::HTTP_UNPROCESSABLE_ENTITY); } @@ -87,7 +97,12 @@ class InventoryController extends BaseApiController public function audit(InventoryAuditRequest $request, int $id): JsonResponse { - $result = $this->items->auditClassroom($id, $request->validated(), (int) auth()->id()); + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $result = $this->items->auditClassroom($id, $request->validated(), $actor); if (!($result['ok'] ?? false)) { return $this->error($result['message'] ?? 'Failed to audit item.', Response::HTTP_UNPROCESSABLE_ENTITY); } @@ -97,7 +112,12 @@ class InventoryController extends BaseApiController public function adjust(InventoryAdjustRequest $request, int $id): JsonResponse { - $result = $this->items->adjustStock($id, $request->validated(), (int) auth()->id()); + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $result = $this->items->adjustStock($id, $request->validated(), $actor); if (!($result['ok'] ?? false)) { return $this->error($result['message'] ?? 'Failed to adjust stock.', Response::HTTP_UNPROCESSABLE_ENTITY); } @@ -122,9 +142,14 @@ class InventoryController extends BaseApiController public function teacherDistribution(InventoryTeacherDistributionRequest $request): JsonResponse { + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + $payload = $request->validated(); $result = $this->distribution->formData( - (int) auth()->id(), + $actor, $payload['class_section_id'] ?? null, $payload['item_id'] ?? null ); @@ -138,8 +163,13 @@ class InventoryController extends BaseApiController public function teacherDistributionStore(InventoryTeacherDistributionStoreRequest $request): JsonResponse { + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + try { - $result = $this->distribution->distribute((int) auth()->id(), $request->validated()); + $result = $this->distribution->distribute($actor, $request->validated()); } catch (\Throwable $e) { Log::error('Inventory distribution failed: ' . $e->getMessage()); return $this->error('Unable to distribute inventory.', Response::HTTP_INTERNAL_SERVER_ERROR); @@ -151,4 +181,17 @@ class InventoryController extends BaseApiController return $this->success($result); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php index cbd19d78..6e18cf08 100644 --- a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php +++ b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Inventory; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Inventory\InventoryMovementBulkDeleteRequest; use App\Http\Requests\Inventory\InventoryMovementIndexRequest; use App\Http\Requests\Inventory\InventoryMovementStoreRequest; @@ -30,7 +30,12 @@ class InventoryMovementController extends BaseApiController public function store(InventoryMovementStoreRequest $request): JsonResponse { - $result = $this->movements->create($request->validated(), (int) auth()->id()); + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $result = $this->movements->create($request->validated(), $actor); if (!($result['ok'] ?? false)) { return $this->error($result['message'] ?? 'Failed to create movement.', Response::HTTP_UNPROCESSABLE_ENTITY); } @@ -67,4 +72,17 @@ class InventoryMovementController extends BaseApiController return $this->success($result); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Inventory/SupplierController.php b/app/Http/Controllers/Api/Inventory/SupplierController.php index a839b925..509488a9 100644 --- a/app/Http/Controllers/Api/Inventory/SupplierController.php +++ b/app/Http/Controllers/Api/Inventory/SupplierController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Inventory; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Inventory\SupplierIndexRequest; use App\Http\Requests\Inventory\SupplierStoreRequest; use App\Http\Requests\Inventory\SupplierUpdateRequest; diff --git a/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php b/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php index cbf92059..4a0b0e89 100644 --- a/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php +++ b/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Inventory; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Inventory\SupplyCategoryStoreRequest; use App\Http\Requests\Inventory\SupplyCategoryUpdateRequest; use App\Http\Resources\Inventory\SupplyCategoryResource; diff --git a/app/Http/Controllers/Api/Messaging/MessagesController.php b/app/Http/Controllers/Api/Messaging/MessagesController.php index f9d51ba0..6f7374ee 100644 --- a/app/Http/Controllers/Api/Messaging/MessagesController.php +++ b/app/Http/Controllers/Api/Messaging/MessagesController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Messaging; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Messaging\MessageIndexRequest; use App\Http\Requests\Messaging\MessageSendRequest; use App\Http\Requests\Messaging\MessageUpdateRequest; @@ -29,9 +29,9 @@ class MessagesController extends BaseApiController public function index(MessageIndexRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $filters = $request->validated(); @@ -40,8 +40,8 @@ class MessagesController extends BaseApiController $this->authorize('viewAny', Message::class); - $role = $this->queryService->getUserRoleName($userId); - $messages = $this->queryService->inbox($userId, $filters, $page, $perPage); + $role = $this->queryService->getUserRoleName($guard); + $messages = $this->queryService->inbox($guard, $filters, $page, $perPage); $collection = new MessageCollection($messages); $meta = $collection->with($request)['meta'] ?? null; @@ -94,15 +94,15 @@ class MessagesController extends BaseApiController public function store(MessageSendRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $this->authorize('create', Message::class); try { - $message = $this->commandService->create($userId, $request->validated(), $request->file('attachment')); + $message = $this->commandService->create($guard, $request->validated(), $request->file('attachment')); } catch (\Throwable $e) { Log::error('Message send failed: ' . $e->getMessage()); return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST); @@ -115,12 +115,12 @@ class MessagesController extends BaseApiController public function receive(): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $messages = $this->queryService->receivedAndMarkRead($userId); + $messages = $this->queryService->receivedAndMarkRead($guard); return $this->success([ 'messages' => (new MessageCollection(collect($messages)))->toArray(request()), @@ -193,9 +193,9 @@ class MessagesController extends BaseApiController private function listMessages(MessageIndexRequest $request, string $bucket): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $filters = $request->validated(); @@ -205,10 +205,10 @@ class MessagesController extends BaseApiController $this->authorize('viewAny', Message::class); $paginator = match ($bucket) { - 'sent' => $this->queryService->sent($userId, $filters, $page, $perPage), - 'drafts' => $this->queryService->drafts($userId, $filters, $page, $perPage), - 'trash' => $this->queryService->trash($userId, $filters, $page, $perPage), - default => $this->queryService->inbox($userId, $filters, $page, $perPage), + 'sent' => $this->queryService->sent($guard, $filters, $page, $perPage), + 'drafts' => $this->queryService->drafts($guard, $filters, $page, $perPage), + 'trash' => $this->queryService->trash($guard, $filters, $page, $perPage), + default => $this->queryService->inbox($guard, $filters, $page, $perPage), }; $collection = new MessageCollection($paginator); @@ -219,4 +219,17 @@ class MessagesController extends BaseApiController 'meta' => $meta, ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Messaging/WhatsappController.php b/app/Http/Controllers/Api/Messaging/WhatsappController.php index f43982c9..a7d52d28 100644 --- a/app/Http/Controllers/Api/Messaging/WhatsappController.php +++ b/app/Http/Controllers/Api/Messaging/WhatsappController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Messaging; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Whatsapp\WhatsappInviteSendRequest; use App\Http\Requests\Whatsapp\WhatsappLinkIndexRequest; use App\Http\Requests\Whatsapp\WhatsappLinkStoreRequest; @@ -132,8 +132,12 @@ class WhatsappController extends BaseApiController public function updateMembership(WhatsappMembershipUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + try { - $userId = (int) (auth()->id() ?? 0) ?: null; $result = $this->membershipService->updateMembership($request->validated(), $userId); } catch (\Throwable $e) { Log::error('WhatsApp membership update failed: ' . $e->getMessage()); @@ -153,4 +157,17 @@ class WhatsappController extends BaseApiController 'term' => $result['term'] ?? [], ], 'Membership saved.'); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Notifications/NotificationController.php b/app/Http/Controllers/Api/Notifications/NotificationController.php index 4a67b97e..f6e189c9 100644 --- a/app/Http/Controllers/Api/Notifications/NotificationController.php +++ b/app/Http/Controllers/Api/Notifications/NotificationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Notifications; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Notifications\NotificationActiveRequest; use App\Http\Requests\Notifications\NotificationDeletedRequest; use App\Http\Requests\Notifications\NotificationIndexRequest; @@ -36,13 +36,13 @@ class NotificationController extends BaseApiController public function index(NotificationIndexRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $payload = $request->validated(); - $result = $this->listService->listForUser($userId, $payload); + $result = $this->listService->listForUser($guard, $payload); return response()->json([ 'ok' => true, @@ -53,12 +53,12 @@ class NotificationController extends BaseApiController public function show(int $notificationId): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $row = $this->showService->getForUser($userId, $notificationId); + $row = $this->showService->getForUser($guard, $notificationId); if (!$row) { return response()->json(['ok' => false, 'message' => 'Notification not found.'], 404); } @@ -71,11 +71,15 @@ class NotificationController extends BaseApiController public function store(NotificationSendRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); try { - $result = $this->sendService->send($payload, $actorId); + $result = $this->sendService->send($payload, $guard); } catch (\Throwable $e) { Log::error('Notification send failed.', ['error' => $e->getMessage()]); return response()->json(['ok' => false, 'message' => 'Failed to send notification.'], 500); @@ -129,12 +133,12 @@ class NotificationController extends BaseApiController public function markRead(int $notificationId): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $ok = $this->readService->markRead($userId, $notificationId); + $ok = $this->readService->markRead($guard, $notificationId); if (!$ok) { return response()->json(['ok' => false, 'message' => 'Notification not found.'], 404); } @@ -162,4 +166,17 @@ class NotificationController extends BaseApiController 'notifications' => $data['notifications'], ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Parents/AuthorizedUserInviteController.php b/app/Http/Controllers/Api/Parents/AuthorizedUserInviteController.php new file mode 100644 index 00000000..b8b550aa --- /dev/null +++ b/app/Http/Controllers/Api/Parents/AuthorizedUserInviteController.php @@ -0,0 +1,111 @@ +query('token') ?? ''); + try { + $result = $this->svc->confirmEmailClick($token); + } catch (\InvalidArgumentException $e) { + if ($request->expectsJson()) { + return response()->json(['message' => $e->getMessage()], 400); + } + + return response( + '' + .'

'.e($e->getMessage()).'

', + 400, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'Confirmed.', + 'redirect_url' => $result['redirect_url'], + ]); + } + + return redirect()->away($result['redirect_url']); + } + + public function setPasswordForm(Request $request, int $authorizedUserId): JsonResponse|Response + { + $token = (string) ($request->query('token') ?? ''); + $row = $this->svc->findActiveSetupRow($authorizedUserId, $token); + if (! $row) { + if ($request->expectsJson()) { + return response()->json(['message' => 'Invalid or expired confirmation link.'], 400); + } + + return response('Invalid or expired confirmation link.', 400); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'OK', + 'user_id' => $authorizedUserId, + 'token' => $token, + ]); + } + + return response( + 'Set password' + .'

Set your password using the client app or API POST to this URL with JSON body.

', + 200, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } + + public function savePassword(Request $request, int $authorizedUserId): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'password' => ['required', 'string', 'min:8', 'regex:/[A-Z]/', 'regex:/[a-z]/', 'regex:/[0-9]/', 'regex:/[\W_]/'], + 'password_confirm' => ['required', 'same:password'], + 'user_id' => ['required', 'integer'], + 'token' => ['required', 'string'], + ], [ + 'password.regex' => 'Password must include uppercase, lowercase, number, and special character.', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $data = $validator->validated(); + + try { + $this->svc->setAuthorizedUserPassword( + $authorizedUserId, + (int) $data['user_id'], + (string) $data['token'], + (string) $data['password'], + ); + } catch (\InvalidArgumentException $e) { + return response()->json(['message' => $e->getMessage()], 400); + } + + return response()->json(['message' => 'Password has been successfully set.']); + } +} diff --git a/app/Http/Controllers/Api/Parents/AuthorizedUsersController.php b/app/Http/Controllers/Api/Parents/AuthorizedUsersController.php new file mode 100644 index 00000000..b0fca933 --- /dev/null +++ b/app/Http/Controllers/Api/Parents/AuthorizedUsersController.php @@ -0,0 +1,141 @@ +guardJsonOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $rows = AuthorizedUser::query() + ->where('user_id', $guard) + ->orderBy('id') + ->get(); + + return $this->success($rows); + } + + public function show(int $id): JsonResponse + { + $guard = $this->guardJsonOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $resolution = $this->authorizedUserForParent($id, $guard); + if ($resolution instanceof JsonResponse) { + return $resolution; + } + + return $this->success($resolution); + } + + public function store(StoreAuthorizedUserRequest $request): JsonResponse + { + $guard = $this->guardJsonOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $email = strtolower($request->validated('email')); + $result = $this->svc->inviteByEmail($guard, $email); + + $message = 'Authorized user added. A confirmation email has been sent.'; + if (! $result['created']) { + return $this->success(['message' => $message], $message, Response::HTTP_CREATED); + } + + return $this->success( + ['message' => $message, 'id' => $result['record']->id], + $message, + Response::HTTP_CREATED + ); + } + + public function update(UpdateAuthorizedUserRequest $request, int $id): JsonResponse + { + $guard = $this->guardJsonOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $resolution = $this->authorizedUserForParent($id, $guard); + if ($resolution instanceof JsonResponse) { + return $resolution; + } + + /** @var AuthorizedUser $row */ + $row = $resolution; + + $email = $request->validated('email') ?? null; + if (is_string($email) && $email !== '') { + $row->email = strtolower($email); + } + + $row->save(); + + return $this->success(['message' => 'Authorized user information updated.'], 'Authorized user information updated.'); + } + + public function destroy(int $id): JsonResponse + { + $guard = $this->guardJsonOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $resolution = $this->authorizedUserForParent($id, $guard); + if ($resolution instanceof JsonResponse) { + return $resolution; + } + + $resolution->delete(); + + return $this->success(['message' => 'Authorized user deleted successfully.'], 'Authorized user deleted successfully.'); + } + + private function guardJsonOrUnauthorized(): int|JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return $this->error('Authentication required.', Response::HTTP_UNAUTHORIZED); + } + + return $parentId; + } + + private function authorizedUserForParent(int $id, int $parentId): AuthorizedUser|JsonResponse + { + $row = AuthorizedUser::query()->find($id); + if (! $row) { + return $this->error('Authorized user not found.', Response::HTTP_NOT_FOUND); + } + + if ((int) $row->user_id !== $parentId) { + return $this->error('You do not have access to this resource.', Response::HTTP_FORBIDDEN); + } + + return $row; + } +} diff --git a/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php b/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php index ca5f4c1b..271ba93c 100644 --- a/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php +++ b/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Parents; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Parents\ParentAttendanceReportSubmitRequest; use App\Http\Requests\Parents\ParentAttendanceReportListRequest; use App\Http\Requests\Parents\ParentAttendanceReportCheckRequest; @@ -20,12 +20,12 @@ class ParentAttendanceReportController extends BaseApiController public function form(): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->reportService->formData($parentId); + $data = $this->reportService->formData($guard); return response()->json([ 'ok' => true, @@ -40,13 +40,13 @@ class ParentAttendanceReportController extends BaseApiController public function submit(ParentAttendanceReportSubmitRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } try { - $result = $this->reportService->submit($parentId, $request->validated()); + $result = $this->reportService->submit($guard, $request->validated()); } catch (\RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); } @@ -62,13 +62,18 @@ class ParentAttendanceReportController extends BaseApiController public function list(ParentAttendanceReportListRequest $request): JsonResponse { + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $start = $payload['start'] ?? now()->toDateString(); $end = $payload['end'] ?? null; $schoolYear = $payload['school_year'] ?? null; $semester = $payload['semester'] ?? null; - $rows = $this->reportService->listReports($start, $end, $schoolYear, $semester); + $rows = $this->reportService->listReports($start, $end, $schoolYear, $semester, $guard); return response()->json([ 'ok' => true, @@ -78,8 +83,13 @@ class ParentAttendanceReportController extends BaseApiController public function checkExisting(ParentAttendanceReportCheckRequest $request): JsonResponse { + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + try { - $rows = $this->reportService->checkExisting($request->validated()); + $rows = $this->reportService->checkExisting($guard, $request->validated()); } catch (\RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); } @@ -92,17 +102,30 @@ class ParentAttendanceReportController extends BaseApiController public function update(ParentAttendanceReportUpdateRequest $request, int $reportId): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } try { - $this->reportService->updateReport($parentId, $reportId, $request->validated()); + $this->reportService->updateReport($guard, $reportId, $request->validated()); } catch (\RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); } return response()->json(['ok' => true]); } + + /** + * @return int|JsonResponse + */ + private function parentIdOrUnauthorized(): int|JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $parentId; + } } diff --git a/app/Http/Controllers/Api/Parents/ParentController.php b/app/Http/Controllers/Api/Parents/ParentController.php index 8337d71a..e651b045 100644 --- a/app/Http/Controllers/Api/Parents/ParentController.php +++ b/app/Http/Controllers/Api/Parents/ParentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Parents; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Parents\ParentAttendanceRequest; use App\Http\Requests\Parents\ParentEnrollmentActionRequest; use App\Http\Requests\Parents\ParentEnrollmentRequest; @@ -41,12 +41,12 @@ class ParentController extends BaseApiController public function attendance(ParentAttendanceRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->attendanceService->listAttendance($parentId, $request->validated()['school_year'] ?? null); + $data = $this->attendanceService->listAttendance($guard, $request->validated()['school_year'] ?? null); return response()->json([ 'ok' => true, @@ -58,13 +58,13 @@ class ParentController extends BaseApiController public function invoices(ParentInvoiceRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $schoolYear = $request->validated()['school_year'] ?? null; - $rows = $this->invoiceService->listInvoices($parentId, $schoolYear); + $rows = $this->invoiceService->listInvoices($guard, $schoolYear); return response()->json([ 'ok' => true, @@ -74,12 +74,12 @@ class ParentController extends BaseApiController public function enrollments(ParentEnrollmentRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->enrollmentService->overview($parentId, $request->validated()['school_year'] ?? null); + $data = $this->enrollmentService->overview($guard, $request->validated()['school_year'] ?? null); return response()->json([ 'ok' => true, @@ -94,14 +94,14 @@ class ParentController extends BaseApiController public function updateEnrollments(ParentEnrollmentActionRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $payload = $request->validated(); $result = $this->enrollmentService->updateEnrollment( - $parentId, + $guard, $payload['enroll'] ?? [], $payload['withdraw'] ?? [] ); @@ -115,12 +115,12 @@ class ParentController extends BaseApiController public function registration(): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->registrationService->overview($parentId); + $data = $this->registrationService->overview($guard); return response()->json([ 'ok' => true, @@ -136,14 +136,14 @@ class ParentController extends BaseApiController public function storeRegistration(ParentRegistrationRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $payload = $request->validated(); $result = $this->registrationService->register( - $parentId, + $guard, $payload['students'] ?? [], $payload['emergency_contacts'] ?? [] ); @@ -156,36 +156,36 @@ class ParentController extends BaseApiController public function updateStudent(ParentStudentUpdateRequest $request, int $studentId): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $this->registrationService->updateStudent($parentId, $studentId, $request->validated()); + $this->registrationService->updateStudent($guard, $studentId, $request->validated()); return response()->json(['ok' => true]); } public function deleteStudent(int $studentId): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $this->registrationService->deleteStudent($parentId, $studentId); + $this->registrationService->deleteStudent($guard, $studentId); return response()->json(['ok' => true]); } public function emergencyContacts(): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $rows = $this->emergencyContactService->list($parentId); + $rows = $this->emergencyContactService->list($guard); return response()->json([ 'ok' => true, @@ -195,12 +195,12 @@ class ParentController extends BaseApiController public function storeEmergencyContact(ParentEmergencyContactRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $contact = $this->emergencyContactService->store($parentId, $request->validated()); + $contact = $this->emergencyContactService->store($guard, $request->validated()); return response()->json([ 'ok' => true, @@ -210,12 +210,12 @@ class ParentController extends BaseApiController public function updateEmergencyContact(ParentEmergencyContactRequest $request, int $contactId): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $contact = $this->emergencyContactService->update($parentId, $contactId, $request->validated()); + $contact = $this->emergencyContactService->update($guard, $contactId, $request->validated()); return response()->json([ 'ok' => true, @@ -225,12 +225,12 @@ class ParentController extends BaseApiController public function profile(): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $profile = $this->profileService->getProfile($parentId); + $profile = $this->profileService->getProfile($guard); return response()->json([ 'ok' => true, @@ -240,12 +240,12 @@ class ParentController extends BaseApiController public function updateProfile(ParentProfileUpdateRequest $request): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $profile = $this->profileService->updateProfile($parentId, $request->validated()); + $profile = $this->profileService->updateProfile($guard, $request->validated()); return response()->json([ 'ok' => true, @@ -255,12 +255,12 @@ class ParentController extends BaseApiController public function events(): JsonResponse { - $parentId = (int) (auth()->id() ?? 0); - if ($parentId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->eventService->overview($parentId); + $data = $this->eventService->overview($guard); return response()->json([ 'ok' => true, @@ -271,14 +271,27 @@ class ParentController extends BaseApiController } public function updateParticipation(ParentEventParticipationRequest $request): JsonResponse + { + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $this->eventService->updateParticipation($guard, $request->validated()['participation'] ?? []); + + return response()->json(['ok' => true]); + } + + /** + * @return int|JsonResponse Authenticated parent user id, or 401 JSON for this controller's legacy `{ ok }` shape. + */ + private function parentIdOrUnauthorized(): int|JsonResponse { $parentId = (int) (auth()->id() ?? 0); if ($parentId <= 0) { return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); } - $this->eventService->updateParticipation($parentId, $request->validated()['participation'] ?? []); - - return response()->json(['ok' => true]); + return $parentId; } } diff --git a/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php b/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php new file mode 100644 index 00000000..76d86d92 --- /dev/null +++ b/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php @@ -0,0 +1,282 @@ +getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $this->success($this->portal->teacherBootstrap($uid)); + } + + public function admin(): JsonResponse + { + return $this->success([ + 'print_requests' => $this->portal->adminPrintRequests(), + ]); + } + + public function store(Request $request): JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $validator = Validator::make($request->all(), [ + 'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'], + 'page_selection' => ['nullable', 'string', 'regex:/^\s*\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*\s*$/'], + 'num_copies' => ['required', 'integer', 'min:1'], + 'required_by' => ['required', 'date'], + 'pickup_method' => ['required', 'string', 'max:255'], + 'class_id' => ['required', 'integer', 'min:1'], + ]); + + if ($validator->fails()) { + return $this->respondValidationError($validator->errors()->toArray()); + } + + /** @var \Illuminate\Http\UploadedFile $file */ + $file = $request->file('file'); + $model = $this->portal->storeTeacherUpload($validator->validated(), $file, $uid); + + return $this->respondCreated( + ['print_request' => $this->portal->normalizePrintRequestRow($model->toArray())], + 'Print request created successfully.' + ); + } + + public function teacherUpdate(Request $request, int $id): JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $pr = PrintRequest::query()->find($id); + if (! $pr) { + return $this->error('Print request not found.', Response::HTTP_NOT_FOUND); + } + + if (! $this->portal->teacherOwnsRequest($pr, $uid)) { + return $this->error('You are not authorized to edit this request.', Response::HTTP_FORBIDDEN); + } + + if (! $this->portal->teacherMayEditOrDelete($pr)) { + return $this->error('Cannot edit a request that is already being processed.', Response::HTTP_BAD_REQUEST); + } + + $rules = [ + 'num_copies' => ['required', 'integer', 'min:1'], + 'required_by' => ['required', 'date'], + 'pickup_method' => ['required', 'string', Rule::in(['Self Pickup', 'Delivered to class'])], + 'page_selection' => ['nullable', 'string', 'regex:/^\s*\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*\s*$/'], + ]; + + if ($request->hasFile('file') && $request->file('file')->isValid()) { + $rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt']; + } + + $validator = Validator::make($request->all(), $rules); + if ($validator->fails()) { + return $this->respondValidationError($validator->errors()->toArray()); + } + + $data = $validator->validated(); + $newFile = ($request->hasFile('file') && $request->file('file')->isValid()) + ? $request->file('file') + : null; + + $model = $this->portal->updateTeacherFields($pr, $data, $newFile); + + return $this->success( + ['print_request' => $this->portal->normalizePrintRequestRow($model->toArray())], + 'Print request updated successfully.' + ); + } + + public function adminStatus(Request $request, int $id): JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $validator = Validator::make($request->all(), [ + 'status' => ['required', 'string', Rule::in(['not_assigned', 'assigned', 'done', 'delivered'])], + ]); + if ($validator->fails()) { + return $this->respondValidationError($validator->errors()->toArray()); + } + + $pr = PrintRequest::query()->find($id); + if (! $pr) { + return $this->error('Print request not found.', Response::HTTP_NOT_FOUND); + } + + $newStatus = (string) $validator->validated()['status']; + + try { + $model = $this->portal->applyAdminStatus($pr, $newStatus, $uid); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success( + ['print_request' => $this->portal->normalizePrintRequestRow($model->toArray())], + 'Status updated successfully.' + ); + } + + public function destroy(int $id): JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $pr = PrintRequest::query()->find($id); + if (! $pr) { + return $this->error('Print request not found.', Response::HTTP_NOT_FOUND); + } + + if (! $this->portal->teacherOwnsRequest($pr, $uid)) { + return $this->error('You are not authorized to delete this request.', Response::HTTP_FORBIDDEN); + } + + if (! $this->portal->teacherMayEditOrDelete($pr)) { + return $this->error('Cannot delete a request that is already being processed.', Response::HTTP_BAD_REQUEST); + } + + $this->portal->deleteTeacherRequest($pr); + + return $this->respondDeleted(null, 'Print request deleted successfully.'); + } + + public function copy(int $id): JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $pr = PrintRequest::query()->find($id); + if (! $pr) { + return $this->error('Print request not found.', Response::HTTP_NOT_FOUND); + } + + if (! $this->portal->teacherOwnsRequest($pr, $uid)) { + return $this->error('You are not authorized to copy this request.', Response::HTTP_FORBIDDEN); + } + + try { + $model = $this->portal->copyFromExisting($pr, $uid); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST); + } + + return $this->respondCreated( + ['print_request' => $this->portal->normalizePrintRequestRow($model->toArray())], + 'Print request copied successfully.' + ); + } + + public function handCopy(Request $request): JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $validator = Validator::make($request->all(), [ + 'num_copies' => ['required', 'integer', 'min:1'], + 'required_by' => ['required', 'date'], + 'pickup_method' => ['required', 'string', 'max:255'], + 'class_id' => ['required', 'integer', 'min:1'], + ]); + + if ($validator->fails()) { + return $this->respondValidationError($validator->errors()->toArray()); + } + + $model = $this->portal->storeHandCopy($validator->validated(), $uid); + + return $this->respondCreated( + ['print_request' => $this->portal->normalizePrintRequestRow($model->toArray())], + 'Copy request submitted. Please hand the original document to the copy center.' + ); + } + + public function download(int $id): BinaryFileResponse|JsonResponse + { + $uid = $this->getCurrentUserId(); + if (! $uid) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + $pr = PrintRequest::query()->find($id); + if (! $pr) { + return $this->error('Print request not found.', Response::HTTP_NOT_FOUND); + } + + $isAdmin = $this->userHasPrintAdminRole(Auth::user()); + if (! $this->portal->authorizeDownload($pr, $uid, $isAdmin)) { + return $this->error('Forbidden.', Response::HTTP_FORBIDDEN); + } + + $path = $this->portal->absolutePathForDownload($pr); + if ($path === null || ! is_readable($path)) { + return $this->error('File not found.', Response::HTTP_NOT_FOUND); + } + + $mime = @mime_content_type($path) ?: 'application/octet-stream'; + $safe = basename($path); + + return response()->download($path, $safe, [ + 'Content-Type' => $mime, + ]); + } + + private function userHasPrintAdminRole(?User $user): bool + { + if (! $user) { + return false; + } + + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $allowed) { + if (in_array($allowed, $roles, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Controllers/Api/Public/PublicWinnersController.php b/app/Http/Controllers/Api/Public/PublicWinnersController.php new file mode 100644 index 00000000..0271b2a7 --- /dev/null +++ b/app/Http/Controllers/Api/Public/PublicWinnersController.php @@ -0,0 +1,43 @@ +json([ + 'status' => true, + 'data' => [ + 'competitions' => $this->winners->publishedCompetitions(), + ], + ]); + } + + public function competitionShow(int $id): JsonResponse + { + $payload = $this->winners->competitionPayload($id); + if ($payload === null) { + return response()->json([ + 'status' => false, + 'message' => 'Competition not found.', + ], 404); + } + + return response()->json([ + 'status' => true, + 'data' => $payload, + ]); + } +} diff --git a/app/Http/Controllers/Api/Reports/FilesController.php b/app/Http/Controllers/Api/Reports/FilesController.php index 29ec1197..0d6a85a9 100644 --- a/app/Http/Controllers/Api/Reports/FilesController.php +++ b/app/Http/Controllers/Api/Reports/FilesController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Reports; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Files\FileNameRequest; use App\Http\Resources\Files\FileMetaResource; use App\Services\Files\ExamDraftDownloadNameService; diff --git a/app/Http/Controllers/Api/Reports/ReportCardsController.php b/app/Http/Controllers/Api/Reports/ReportCardsController.php index 4b019eb7..32a00196 100644 --- a/app/Http/Controllers/Api/Reports/ReportCardsController.php +++ b/app/Http/Controllers/Api/Reports/ReportCardsController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Reports; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Reports\ReportCards\ReportCardAcknowledgementRequest; use App\Http\Requests\Reports\ReportCards\ReportCardCompletenessRequest; use App\Http\Requests\Reports\ReportCards\ReportCardMetaRequest; diff --git a/app/Http/Controllers/Api/Reports/SlipPrinterController.php b/app/Http/Controllers/Api/Reports/SlipPrinterController.php index f4de6ac6..44b3685e 100644 --- a/app/Http/Controllers/Api/Reports/SlipPrinterController.php +++ b/app/Http/Controllers/Api/Reports/SlipPrinterController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Reports; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Reports\SlipLogListRequest; use App\Http\Requests\Reports\SlipPreviewRequest; use App\Http\Requests\Reports\SlipPrintRequest; @@ -20,8 +20,12 @@ class SlipPrinterController extends BaseApiController public function print(SlipPrintRequest $request) { - $userId = (int) (auth()->id() ?? 0); - $result = $this->service->print($request->validated(), $userId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->service->print($request->validated(), $guard); if (!$result['ok']) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Print failed.'], 422); @@ -35,8 +39,12 @@ class SlipPrinterController extends BaseApiController public function preview(SlipPreviewRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - $result = $this->service->preview($request->validated(), $userId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->service->preview($request->validated(), $guard); return response()->json([ 'ok' => true, @@ -47,6 +55,11 @@ class SlipPrinterController extends BaseApiController public function logs(SlipLogListRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); $rows = $this->service->logs($payload['school_year'] ?? null, $payload['semester'] ?? null); @@ -58,8 +71,12 @@ class SlipPrinterController extends BaseApiController public function reprint(SlipReprintRequest $request) { - $userId = (int) (auth()->id() ?? 0); - $result = $this->service->reprint((int) $request->validated()['id'], $userId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->service->reprint((int) $request->validated()['id'], $guard); if (!$result['ok']) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Slip not found.'], 404); @@ -70,4 +87,17 @@ class SlipPrinterController extends BaseApiController 'Content-Disposition' => 'inline; filename="' . $result['filename'] . '"', ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Reports/StickersController.php b/app/Http/Controllers/Api/Reports/StickersController.php index 003da195..a5129532 100644 --- a/app/Http/Controllers/Api/Reports/StickersController.php +++ b/app/Http/Controllers/Api/Reports/StickersController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Reports; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Reports\Stickers\StickerFormDataRequest; use App\Http\Requests\Reports\Stickers\StickerPrintRequest; use App\Http\Requests\Reports\Stickers\StickerStudentsByClassRequest; diff --git a/app/Http/Controllers/Api/Scores/FinalController.php b/app/Http/Controllers/Api/Scores/FinalController.php index e364e5b7..0c4e432f 100644 --- a/app/Http/Controllers/Api/Scores/FinalController.php +++ b/app/Http/Controllers/Api/Scores/FinalController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreUpdateRequest; use App\Http\Resources\Scores\ScoreStudentResource; use App\Models\Student; @@ -52,6 +52,11 @@ class FinalController extends BaseApiController public function update(ScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->service->update( 'final_exam', @@ -60,10 +65,10 @@ class FinalController extends BaseApiController (string) $payload['school_year'], $payload['scores'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); - $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $userId); return response()->json(['ok' => true, 'updated' => $count]); } @@ -93,9 +98,9 @@ class FinalController extends BaseApiController ]); } - private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear, int $actorUserId): void { - $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $actorUserId); if (empty($studentInfo)) { return; } @@ -104,4 +109,17 @@ class FinalController extends BaseApiController } catch (\Throwable $e) { } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/HomeworkController.php b/app/Http/Controllers/Api/Scores/HomeworkController.php index dc2b9214..d5fe6beb 100644 --- a/app/Http/Controllers/Api/Scores/HomeworkController.php +++ b/app/Http/Controllers/Api/Scores/HomeworkController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreAddColumnRequest; use App\Http\Requests\Scores\ScoreUpdateRequest; use App\Http\Resources\Scores\ScoreStudentResource; @@ -58,6 +58,11 @@ class HomeworkController extends BaseApiController public function update(ScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->service->update( (int) $payload['class_section_id'], @@ -65,22 +70,27 @@ class HomeworkController extends BaseApiController (string) $payload['school_year'], $payload['scores'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); - $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $userId); return response()->json(['ok' => true, 'updated' => $count]); } public function addColumn(ScoreAddColumnRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $nextIndex = $this->service->addColumn( (int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true, 'homework_index' => $nextIndex]); @@ -111,9 +121,9 @@ class HomeworkController extends BaseApiController ]); } - private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear, int $actorUserId): void { - $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $actorUserId); if (empty($studentInfo)) { return; } @@ -122,4 +132,17 @@ class HomeworkController extends BaseApiController } catch (\Throwable $e) { } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/MidtermController.php b/app/Http/Controllers/Api/Scores/MidtermController.php index 64d84eed..98825cf6 100644 --- a/app/Http/Controllers/Api/Scores/MidtermController.php +++ b/app/Http/Controllers/Api/Scores/MidtermController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreUpdateRequest; use App\Http\Resources\Scores\ScoreStudentResource; use App\Models\Student; @@ -52,6 +52,11 @@ class MidtermController extends BaseApiController public function update(ScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->service->update( 'midterm_exam', @@ -60,10 +65,10 @@ class MidtermController extends BaseApiController (string) $payload['school_year'], $payload['scores'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); - $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $userId); return response()->json(['ok' => true, 'updated' => $count]); } @@ -93,9 +98,9 @@ class MidtermController extends BaseApiController ]); } - private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear, int $actorUserId): void { - $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $actorUserId); if (empty($studentInfo)) { return; } @@ -104,4 +109,17 @@ class MidtermController extends BaseApiController } catch (\Throwable $e) { } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/ParticipationController.php b/app/Http/Controllers/Api/Scores/ParticipationController.php index e73f8742..68341a9a 100644 --- a/app/Http/Controllers/Api/Scores/ParticipationController.php +++ b/app/Http/Controllers/Api/Scores/ParticipationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreUpdateRequest; use App\Http\Resources\Scores\ScoreStudentResource; use App\Models\Student; @@ -56,6 +56,11 @@ class ParticipationController extends BaseApiController public function update(ScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->service->update( (int) $payload['class_section_id'], @@ -63,10 +68,10 @@ class ParticipationController extends BaseApiController (string) $payload['school_year'], $payload['scores'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); - $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $userId); return response()->json(['ok' => true, 'updated' => $count]); } @@ -96,9 +101,9 @@ class ParticipationController extends BaseApiController ]); } - private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear, int $actorUserId): void { - $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $actorUserId); if (empty($studentInfo)) { return; } @@ -107,4 +112,17 @@ class ParticipationController extends BaseApiController } catch (\Throwable $e) { } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/ProjectController.php b/app/Http/Controllers/Api/Scores/ProjectController.php index a5898497..2a564586 100644 --- a/app/Http/Controllers/Api/Scores/ProjectController.php +++ b/app/Http/Controllers/Api/Scores/ProjectController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreAddColumnRequest; use App\Http\Requests\Scores\ScoreUpdateRequest; use App\Http\Resources\Scores\ScoreStudentResource; @@ -58,6 +58,11 @@ class ProjectController extends BaseApiController public function update(ScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->service->update( (int) $payload['class_section_id'], @@ -65,22 +70,27 @@ class ProjectController extends BaseApiController (string) $payload['school_year'], $payload['scores'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); - $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $userId); return response()->json(['ok' => true, 'updated' => $count]); } public function addColumn(ScoreAddColumnRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $nextIndex = $this->service->addColumn( (int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true, 'project_index' => $nextIndex]); @@ -111,9 +121,9 @@ class ProjectController extends BaseApiController ]); } - private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear, int $actorUserId): void { - $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $actorUserId); if (empty($studentInfo)) { return; } @@ -123,4 +133,17 @@ class ProjectController extends BaseApiController // swallow errors to keep API response stable } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/QuizController.php b/app/Http/Controllers/Api/Scores/QuizController.php index 33cba3c7..24af02a0 100644 --- a/app/Http/Controllers/Api/Scores/QuizController.php +++ b/app/Http/Controllers/Api/Scores/QuizController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreAddColumnRequest; use App\Http\Requests\Scores\ScoreUpdateRequest; use App\Http\Resources\Scores\ScoreStudentResource; @@ -58,6 +58,11 @@ class QuizController extends BaseApiController public function update(ScoreUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $count = $this->service->update( (int) $payload['class_section_id'], @@ -65,22 +70,27 @@ class QuizController extends BaseApiController (string) $payload['school_year'], $payload['scores'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); - $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $userId); return response()->json(['ok' => true, 'updated' => $count]); } public function addColumn(ScoreAddColumnRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $nextIndex = $this->service->addColumn( (int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], - (int) (auth()->id() ?? 0) + $userId ); return response()->json(['ok' => true, 'quiz_index' => $nextIndex]); @@ -111,9 +121,9 @@ class QuizController extends BaseApiController ]); } - private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear, int $actorUserId): void { - $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $actorUserId); if (empty($studentInfo)) { return; } @@ -122,4 +132,17 @@ class QuizController extends BaseApiController } catch (\Throwable $e) { } } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/ScoreCommentController.php b/app/Http/Controllers/Api/Scores/ScoreCommentController.php index c1e96730..1943185f 100644 --- a/app/Http/Controllers/Api/Scores/ScoreCommentController.php +++ b/app/Http/Controllers/Api/Scores/ScoreCommentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreCommentListRequest; use App\Http\Requests\Scores\ScoreCommentSaveRequest; use App\Http\Requests\Scores\ScoreCommentUpdateRequest; @@ -72,6 +72,11 @@ class ScoreCommentController extends BaseApiController public function store(ScoreCommentSaveRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $result = $this->service->save( (int) $payload['class_section_id'], @@ -79,7 +84,7 @@ class ScoreCommentController extends BaseApiController (string) $payload['school_year'], $payload['comments'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); if (!empty($result['errors'])) { @@ -91,6 +96,11 @@ class ScoreCommentController extends BaseApiController public function update(ScoreCommentUpdateRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $result = $this->service->update( (int) $payload['class_section_id'], @@ -98,7 +108,7 @@ class ScoreCommentController extends BaseApiController (string) $payload['school_year'], $payload['comments'] ?? [], $payload['reviews'] ?? [], - (int) (auth()->id() ?? 0) + $userId ); if (!empty($result['errors'])) { @@ -107,4 +117,17 @@ class ScoreCommentController extends BaseApiController return response()->json(['ok' => true]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/ScoreController.php b/app/Http/Controllers/Api/Scores/ScoreController.php index 821e9371..7f811f52 100644 --- a/app/Http/Controllers/Api/Scores/ScoreController.php +++ b/app/Http/Controllers/Api/Scores/ScoreController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScoreLockRequest; use App\Http\Requests\Scores\ScoreOverviewRequest; use App\Http\Requests\Scores\ScoreStudentRequest; @@ -19,8 +19,12 @@ class ScoreController extends BaseApiController public function overview(ScoreOverviewRequest $request): JsonResponse { + $teacherId = $this->authenticatedUserIdOrUnauthorized(); + if ($teacherId instanceof JsonResponse) { + return $teacherId; + } + $payload = $request->validated(); - $teacherId = (int) ($request->user()?->id ?? (auth()->id() ?? 0)); $classSectionId = $payload['class_section_id'] ?? $request->input('class_section_id'); $semester = $payload['semester'] ?? $request->input('semester'); $schoolYear = $payload['school_year'] ?? $request->input('school_year'); @@ -45,13 +49,18 @@ class ScoreController extends BaseApiController public function lock(ScoreLockRequest $request): JsonResponse { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + $payload = $request->validated(); $this->service->submitScoresLock( (int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year'], $payload['missing_ok'] ?? [], - (int) ($request->user()?->id ?? (auth()->id() ?? 0)) + $userId ); return response()->json(['ok' => true]); @@ -59,11 +68,28 @@ class ScoreController extends BaseApiController public function studentScores(ScoreStudentRequest $request): JsonResponse { + $parentId = $this->authenticatedUserIdOrUnauthorized(); + if ($parentId instanceof JsonResponse) { + return $parentId; + } + $payload = $request->validated(); - $parentId = (int) ($request->user()?->id ?? (auth()->id() ?? 0)); $data = $this->service->viewStudentScores($parentId, (string) $payload['school_year']); return response()->json(['ok' => true] + $data); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (request()->user()?->id ?? auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Scores/ScorePredictorController.php b/app/Http/Controllers/Api/Scores/ScorePredictorController.php index 1f69533e..ec4f6c8f 100644 --- a/app/Http/Controllers/Api/Scores/ScorePredictorController.php +++ b/app/Http/Controllers/Api/Scores/ScorePredictorController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Scores; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Scores\ScorePredictorRequest; use App\Http\Resources\Scores\ScorePredictorStudentResource; use App\Services\Scores\ScorePredictorService; diff --git a/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php b/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php index 522650a8..b95add77 100644 --- a/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php +++ b/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Settings; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Settings\ConfigurationStoreRequest; use App\Http\Requests\Settings\ConfigurationUpdateRequest; use App\Http\Resources\Settings\ConfigurationResource; diff --git a/app/Http/Controllers/Api/Settings/EventController.php b/app/Http/Controllers/Api/Settings/EventController.php index 80af70c6..f245ccf8 100644 --- a/app/Http/Controllers/Api/Settings/EventController.php +++ b/app/Http/Controllers/Api/Settings/EventController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Settings; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Events\EventChargeIndexRequest; use App\Http\Requests\Events\EventChargeUpdateRequest; use App\Http\Requests\Events\EventIndexRequest; @@ -62,11 +62,15 @@ class EventController extends BaseApiController public function store(EventStoreRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); try { - $result = $this->managementService->create($payload, $request->file('flyer'), $actorId); + $result = $this->managementService->create($payload, $request->file('flyer'), $guard); } catch (\Throwable $e) { Log::error('Event creation failed.', ['error' => $e->getMessage()]); return response()->json(['ok' => false, 'message' => 'Failed to create event.'], 500); @@ -100,8 +104,12 @@ class EventController extends BaseApiController public function destroy(int $eventId): JsonResponse { - $actorId = (int) (auth()->id() ?? 0); - $result = $this->managementService->delete($eventId, $actorId); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->managementService->delete($eventId, $guard); if (empty($result['ok'])) { return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to delete event.'], 404); @@ -124,8 +132,12 @@ class EventController extends BaseApiController public function updateCharges(EventChargeUpdateRequest $request, int $eventId): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $payload = $request->validated(); - $actorId = (int) (auth()->id() ?? 0); $result = $this->chargeService->updateParticipation( (int) $payload['parent_id'], @@ -133,7 +145,7 @@ class EventController extends BaseApiController $payload['participation'], (string) $payload['school_year'], (string) $payload['semester'], - $actorId + $guard ); if (empty($result['ok'])) { @@ -163,4 +175,17 @@ class EventController extends BaseApiController 'students' => EventStudentChargeResource::collection($students), ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Settings/PolicyController.php b/app/Http/Controllers/Api/Settings/PolicyController.php index 6cb589a8..75cd7a30 100644 --- a/app/Http/Controllers/Api/Settings/PolicyController.php +++ b/app/Http/Controllers/Api/Settings/PolicyController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Settings; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Settings\PolicyShowRequest; use App\Http\Resources\Settings\PolicyResource; use App\Services\Policy\PolicyContentService; diff --git a/app/Http/Controllers/Api/Settings/PreferencesController.php b/app/Http/Controllers/Api/Settings/PreferencesController.php index bf6470aa..2a93b8c2 100644 --- a/app/Http/Controllers/Api/Settings/PreferencesController.php +++ b/app/Http/Controllers/Api/Settings/PreferencesController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Settings; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Preferences\PreferencesIndexRequest; use App\Http\Requests\Preferences\PreferencesUpsertRequest; use App\Http\Resources\Preferences\PreferencesCollection; @@ -42,12 +42,12 @@ class PreferencesController extends BaseApiController public function show(): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $payload = $this->queryService->getForUser($userId); + $payload = $this->queryService->getForUser($guard); return $this->success([ 'preferences' => new PreferencesResource($payload['preferences']), @@ -74,19 +74,19 @@ class PreferencesController extends BaseApiController public function store(PreferencesUpsertRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } try { - $saved = $this->commandService->upsert($userId, $request->validated()); + $saved = $this->commandService->upsert($guard, $request->validated()); } catch (\Throwable $e) { Log::error('Preferences save failed: ' . $e->getMessage()); return $this->error('Unable to save preferences.', Response::HTTP_INTERNAL_SERVER_ERROR); } - $payload = $this->queryService->getForUser($userId); + $payload = $this->queryService->getForUser($guard); return $this->success([ 'preferences' => new PreferencesResource($payload['preferences']), @@ -136,4 +136,17 @@ class PreferencesController extends BaseApiController return $this->success(null, 'Preferences deleted.'); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Settings/SchoolCalendarController.php b/app/Http/Controllers/Api/Settings/SchoolCalendarController.php index a7060c60..19489819 100644 --- a/app/Http/Controllers/Api/Settings/SchoolCalendarController.php +++ b/app/Http/Controllers/Api/Settings/SchoolCalendarController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Settings; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Settings\SchoolCalendarIndexRequest; use App\Http\Requests\Settings\SchoolCalendarOptionsRequest; use App\Http\Requests\Settings\SchoolCalendarStoreRequest; @@ -61,7 +61,14 @@ class SchoolCalendarController extends BaseApiController })->all(); if ($includeMeetings) { - $parentUserId = $audience === 'parent' ? (int) (auth()->id() ?? 0) : null; + $parentUserId = null; + if ($audience === 'parent') { + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + $parentUserId = $actor; + } $meetingRows = $this->meetingService->listMeetings($filters['school_year'] ?? '', $audience, $parentUserId); foreach ($meetingRows as $row) { $formatted[] = $this->formatterService->formatMeetingEvent($row, $audience); @@ -151,4 +158,17 @@ class SchoolCalendarController extends BaseApiController 'admins' => !empty($payload['send_email_admin']), ]; } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Settings/SettingsController.php b/app/Http/Controllers/Api/Settings/SettingsController.php index fed17162..2c37f826 100644 --- a/app/Http/Controllers/Api/Settings/SettingsController.php +++ b/app/Http/Controllers/Api/Settings/SettingsController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Settings; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Settings\SettingsUpdateRequest; use App\Http\Resources\Settings\SettingsResource; use App\Models\Setting; @@ -32,13 +32,13 @@ class SettingsController extends BaseApiController $settings = Setting::singleton(); $this->authorize('update', $settings); - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } try { - $updated = $this->settingsService->update($request->validated(), $userId); + $updated = $this->settingsService->update($request->validated(), $guard); } catch (\Throwable $e) { Log::error('Settings update failed: ' . $e->getMessage()); return $this->error('Unable to update settings.', Response::HTTP_INTERNAL_SERVER_ERROR); @@ -48,4 +48,17 @@ class SettingsController extends BaseApiController 'settings' => new SettingsResource($updated), ], 'Settings updated.'); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Staff/StaffController.php b/app/Http/Controllers/Api/Staff/StaffController.php index f0aa39aa..38418b0e 100644 --- a/app/Http/Controllers/Api/Staff/StaffController.php +++ b/app/Http/Controllers/Api/Staff/StaffController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Staff; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Staff\StaffIndexRequest; use App\Http\Requests\Staff\StaffStoreRequest; use App\Http\Requests\Staff\StaffUpdateRequest; diff --git a/app/Http/Controllers/Api/Staff/TeacherController.php b/app/Http/Controllers/Api/Staff/TeacherController.php index df8b840c..9a10ec07 100644 --- a/app/Http/Controllers/Api/Staff/TeacherController.php +++ b/app/Http/Controllers/Api/Staff/TeacherController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Staff; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Teachers\TeacherAbsenceSubmitRequest; use App\Http\Requests\Teachers\TeacherClassViewRequest; use App\Http\Requests\Teachers\TeacherSwitchClassRequest; @@ -25,21 +25,21 @@ class TeacherController extends BaseApiController public function classes(TeacherClassViewRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } try { $data = $this->dashboardService->classView( - $userId, + $guard, $request->validated()['class_section_id'] ?? null ); } catch (\RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 403); } - $teacher = $this->teacherPayload($data['teacher'] ?? null, $userId); + $teacher = $this->teacherPayload($data['teacher'] ?? null, $guard); $studentsBySection = []; foreach (($data['studentsBySection'] ?? []) as $sectionId => $students) { @@ -59,15 +59,15 @@ class TeacherController extends BaseApiController public function switchClass(TeacherSwitchClassRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $requested = (int) $request->validated()['class_section_id']; try { - $data = $this->dashboardService->classView($userId, $requested); + $data = $this->dashboardService->classView($guard, $requested); } catch (\RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 403); } @@ -87,12 +87,12 @@ class TeacherController extends BaseApiController public function absenceForm(): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $data = $this->absenceService->formData($userId); + $data = $this->absenceService->formData($guard); return response()->json([ 'ok' => true, @@ -106,12 +106,12 @@ class TeacherController extends BaseApiController public function submitAbsence(TeacherAbsenceSubmitRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $result = $this->absenceService->submit($userId, $request->validated()); + $result = $this->absenceService->submit($guard, $request->validated()); return response()->json([ 'ok' => (bool) ($result['ok'] ?? false), @@ -140,4 +140,17 @@ class TeacherController extends BaseApiController 'role' => User::getUserRoleName($userId), ]; } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Staff/TimeOffNotificationController.php b/app/Http/Controllers/Api/Staff/TimeOffNotificationController.php new file mode 100644 index 00000000..c63a8b0f --- /dev/null +++ b/app/Http/Controllers/Api/Staff/TimeOffNotificationController.php @@ -0,0 +1,83 @@ +tokens->parseToken($token); + + if (! $payload) { + return $this->respondHtml('Sorry, this link is invalid or has expired.', 400); + } + + $email = trim((string) ($payload['email'] ?? '')); + $fullName = trim((string) ($payload['name'] ?? '')); + + if ($email === '') { + return $this->respondHtml('Unable to notify the requester because their email was not included.', 400); + } + + $dates = (string) ($payload['dates'] ?? '-'); + $role = (string) ($payload['role'] ?? 'staff'); + $reason = (string) ($payload['reason'] ?? ''); + $reasonType = (string) ($payload['reason_type'] ?? ''); + $submittedAt = (string) ($payload['submitted_at'] ?? ''); + $origin = (string) ($payload['origin'] ?? 'staff portal'); + + try { + $subject = 'TimeOff Request - Principal Acknowledgment'; + $body = '
' + .'

Dear '.e($fullName !== '' ? $fullName : 'Staff Member').',

' + .'

This is a courtesy confirmation that your time-off request submitted via the ' + .e($origin).' has been reviewed by the principal.

' + .'' + .'' + .'' + .'' + .'' + .($submittedAt !== '' ? '' : '') + .'
Role'.e($role).'
Dates'.e($dates !== '' ? $dates : '-').'
Reason Type'.e($reasonType !== '' ? $reasonType : '-').'
Reason'.e($reason !== '' ? $reason : '-').'
Submitted At'.e($submittedAt).'
' + .'

If you have any follow-up questions, please contact the principal\'s office directly.

' + .'

Thank you,
Al Rahma School Administration

' + .'
'; + + $this->mail->send($email, $subject, $body, 'notifications'); + } catch (\Throwable $e) { + logger()->error('Failed to send TimeOff confirmation to requester: '.$e->getMessage()); + + return $this->respondHtml( + 'Something went wrong while sending the confirmation email. Please contact IT for help.', + 500 + ); + } + + return $this->respondHtml('Confirmation email sent to the requester. You may close this window.'); + } + + private function respondHtml(string $message, int $statusCode = 200): Response + { + $html = 'TimeOff' + .'' + .'

'.e($message).'

' + .''; + + return response($html, $statusCode, [ + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + } +} diff --git a/app/Http/Controllers/Api/Students/StudentController.php b/app/Http/Controllers/Api/Students/StudentController.php index 9f023f77..f7ac6005 100644 --- a/app/Http/Controllers/Api/Students/StudentController.php +++ b/app/Http/Controllers/Api/Students/StudentController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Students; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Students\StudentAssignClassRequest; use App\Http\Requests\Students\StudentAssignmentListRequest; use App\Http\Requests\Students\StudentAutoDistributeRequest; @@ -676,10 +676,10 @@ class StudentController extends BaseApiController return response()->json(['ok' => true, 'row' => $contact]); } - public function updateRfid(Request $request, int $studentId): JsonResponse + public function updateBadgeScan(Request $request, int $studentId): JsonResponse { $validator = Validator::make($request->all(), [ - 'rfid_tag' => ['required', 'string', 'max:100'], + 'badge_scan' => ['required', 'string', 'max:100'], ]); if ($validator->fails()) { @@ -694,7 +694,7 @@ class StudentController extends BaseApiController return response()->json(['ok' => false, 'message' => 'Student not found.'], 404); } - $student->update(['rfid_tag' => $validator->validated()['rfid_tag']]); + $student->update(['rfid_tag' => $validator->validated()['badge_scan']]); return response()->json(['ok' => true]); } diff --git a/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php b/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php index be205df1..0990ad02 100644 --- a/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php +++ b/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Subjects; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Subjects\SubjectCurriculumStoreRequest; use App\Http\Requests\Subjects\SubjectCurriculumUpdateRequest; use App\Http\Resources\Subjects\SubjectClassResource; diff --git a/app/Http/Controllers/Api/Support/ContactController.php b/app/Http/Controllers/Api/Support/ContactController.php index 8f40429d..a9b3ef7b 100644 --- a/app/Http/Controllers/Api/Support/ContactController.php +++ b/app/Http/Controllers/Api/Support/ContactController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Support; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Support\ContactSendRequest; use App\Http\Resources\Support\ContactMessageResource; use App\Services\Support\ContactMessageService; diff --git a/app/Http/Controllers/Api/Support/SupportController.php b/app/Http/Controllers/Api/Support/SupportController.php index db7eb7d1..beb0522d 100644 --- a/app/Http/Controllers/Api/Support/SupportController.php +++ b/app/Http/Controllers/Api/Support/SupportController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Support; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Support\SupportRequestIndexRequest; use App\Http\Requests\Support\SupportRequestStoreRequest; use App\Http\Resources\Support\SupportRequestCollection; @@ -22,9 +22,9 @@ class SupportController extends BaseApiController public function index(SupportRequestIndexRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } $this->authorize('viewAny', SupportRequest::class); @@ -39,7 +39,7 @@ class SupportController extends BaseApiController ->intersect(['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal']) ->isNotEmpty(); - $paginator = $this->supportService->list($userId, $filters, $page, $perPage, $isAdmin); + $paginator = $this->supportService->list($guard, $filters, $page, $perPage, $isAdmin); $collection = new SupportRequestCollection($paginator); return $this->success([ @@ -50,8 +50,13 @@ class SupportController extends BaseApiController public function store(SupportRequestStoreRequest $request): JsonResponse { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + $user = auth()->user(); - if (!$user) { + if (! $user) { return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); } @@ -62,7 +67,7 @@ class SupportController extends BaseApiController $payload['user_name'] = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')); try { - $result = $this->supportService->create($user->id, $payload); + $result = $this->supportService->create((int) $user->id, $payload); } catch (\Throwable $e) { Log::error('Support request failed: ' . $e->getMessage()); return $this->error('Unable to submit support request.', Response::HTTP_INTERNAL_SERVER_ERROR); @@ -80,4 +85,17 @@ class SupportController extends BaseApiController 'page' => 'teacher_support', ]); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/System/AccessDeniedController.php b/app/Http/Controllers/Api/System/AccessDeniedController.php new file mode 100644 index 00000000..0ea77d3c --- /dev/null +++ b/app/Http/Controllers/Api/System/AccessDeniedController.php @@ -0,0 +1,19 @@ +json([ + 'status' => false, + 'error' => 'forbidden', + 'message' => 'Access denied.', + ], 403); + } +} diff --git a/app/Http/Controllers/Api/System/DashboardController.php b/app/Http/Controllers/Api/System/DashboardController.php index e81e8fc2..0acb1e99 100644 --- a/app/Http/Controllers/Api/System/DashboardController.php +++ b/app/Http/Controllers/Api/System/DashboardController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\System; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Resources\System\DashboardRouteResource; use App\Services\Dashboard\DashboardRouteService; use Illuminate\Http\JsonResponse; diff --git a/app/Http/Controllers/Api/System/DashboardRedirectController.php b/app/Http/Controllers/Api/System/DashboardRedirectController.php new file mode 100644 index 00000000..d4de52d8 --- /dev/null +++ b/app/Http/Controllers/Api/System/DashboardRedirectController.php @@ -0,0 +1,32 @@ +user(); + if (!$user) { + return redirect()->guest($this->urls->webLoginUrl()); + } + + $payload = $this->service->resolveForUser($user); + + return redirect()->to($payload['route']); + } +} diff --git a/app/Http/Controllers/Api/System/DatabaseHealthController.php b/app/Http/Controllers/Api/System/DatabaseHealthController.php index 7e30e288..92f9995f 100644 --- a/app/Http/Controllers/Api/System/DatabaseHealthController.php +++ b/app/Http/Controllers/Api/System/DatabaseHealthController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\System; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Services\System\DatabaseHealthService; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; diff --git a/app/Http/Controllers/Api/System/HealthController.php b/app/Http/Controllers/Api/System/HealthController.php index 7c9ab1ef..471bf0f5 100644 --- a/app/Http/Controllers/Api/System/HealthController.php +++ b/app/Http/Controllers/Api/System/HealthController.php @@ -2,8 +2,7 @@ namespace App\Http\Controllers\Api\System; -use App\Http\Controllers\Api\BaseApiController; -use App\Http\Resources\System\HealthStatusResource; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Services\System\HealthCheckService; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -20,8 +19,7 @@ class HealthController extends BaseApiController $payload = $this->service->check(); $status = $payload['ok'] ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE; - return $this->success([ - 'health' => new HealthStatusResource($payload), - ], 'Health check.', $status); + // CodeIgniter admin/API health returns a flat JSON body (no Laravel success envelope). + return response()->json($payload, $status); } } diff --git a/app/Http/Controllers/Api/System/NavBuilderController.php b/app/Http/Controllers/Api/System/NavBuilderController.php index 44c7c474..eecb1ca7 100644 --- a/app/Http/Controllers/Api/System/NavBuilderController.php +++ b/app/Http/Controllers/Api/System/NavBuilderController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\System; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\System\NavItemReorderRequest; use App\Http\Requests\System\NavItemStoreRequest; use App\Http\Resources\System\NavBuilderDataResource; diff --git a/app/Http/Controllers/Api/System/SchoolIdController.php b/app/Http/Controllers/Api/System/SchoolIdController.php index 9c076ac0..ba43b074 100644 --- a/app/Http/Controllers/Api/System/SchoolIdController.php +++ b/app/Http/Controllers/Api/System/SchoolIdController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\System; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\SchoolIds\SchoolIdAssignRequest; use App\Http\Resources\SchoolIds\SchoolIdResource; use App\Services\SchoolIds\SchoolIdAssignmentService; diff --git a/app/Http/Controllers/Api/System/SemesterRangeController.php b/app/Http/Controllers/Api/System/SemesterRangeController.php index eca196d3..2fdb0f8f 100644 --- a/app/Http/Controllers/Api/System/SemesterRangeController.php +++ b/app/Http/Controllers/Api/System/SemesterRangeController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\System; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Semesters\SchoolYearRangeRequest; use App\Http\Requests\Semesters\SemesterNormalizeRequest; use App\Http\Requests\Semesters\SemesterRangeRequest; diff --git a/app/Http/Controllers/Api/System/StatsController.php b/app/Http/Controllers/Api/System/StatsController.php new file mode 100644 index 00000000..30e7ba58 --- /dev/null +++ b/app/Http/Controllers/Api/System/StatsController.php @@ -0,0 +1,29 @@ +stats = model(Stats::class); + } + + public function index() + { + try { + $stats = $this->stats->findAll(); + return $this->success($stats, 'Statistics retrieved successfully'); + } catch (\Throwable $e) { + log_message('error', 'Stats retrieval error: ' . $e->getMessage()); + return $this->respondError('Failed to retrieve statistics', Response::HTTP_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/app/Http/Controllers/Api/Ui/UiController.php b/app/Http/Controllers/Api/Ui/UiController.php index f962200c..9a3f177c 100644 --- a/app/Http/Controllers/Api/Ui/UiController.php +++ b/app/Http/Controllers/Api/Ui/UiController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Ui; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Ui\UiStyleRequest; use App\Http\Resources\Ui\UiStyleResource; use App\Services\Ui\UiStyleService; @@ -18,15 +18,28 @@ class UiController extends BaseApiController public function style(UiStyleRequest $request): JsonResponse { - $userId = (int) (auth()->id() ?? 0); - if ($userId <= 0) { - return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; } - $prefs = $this->styleService->update($userId, $request->validated()); + $prefs = $this->styleService->update($guard, $request->validated()); return $this->success([ 'preferences' => new UiStyleResource($prefs), ], 'Style updated.'); } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } + + return $userId; + } } diff --git a/app/Http/Controllers/Api/Users/UserController.php b/app/Http/Controllers/Api/Users/UserController.php new file mode 100644 index 00000000..7362e792 --- /dev/null +++ b/app/Http/Controllers/Api/Users/UserController.php @@ -0,0 +1,144 @@ +authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $payload = $request->validated(); + $users = $this->listService->list($payload['sort'] ?? null, $payload['order'] ?? null); + + return response()->json([ + 'ok' => true, + 'users' => UserWithRolesResource::collection($users), + ]); + } + + public function store(UserStoreRequest $request): JsonResponse + { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $payload = $request->validated(); + $result = $this->managementService->create($payload, $guard); + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to create user.', + ], 422); + } + + return response()->json([ + 'ok' => true, + 'user_id' => $result['user']->id ?? null, + ], 201); + } + + public function update(UserUpdateRequest $request, int $userId): JsonResponse + { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $payload = $request->validated(); + $result = $this->managementService->update($userId, $payload); + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to update user.', + ], 422); + } + + return response()->json([ + 'ok' => true, + ]); + } + + public function destroy(int $userId): JsonResponse + { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $result = $this->managementService->delete($userId); + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to delete user.', + ], 404); + } + + return response()->json([ + 'ok' => true, + ]); + } + + public function loginActivity(LoginActivityRequest $request): JsonResponse + { + $guard = $this->authenticatedUserIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $payload = $request->validated(); + $perPage = $payload['per_page'] ?? 25; + $page = $payload['page'] ?? 1; + + $result = $this->loginActivityService->list( + (int) $perPage, + (int) $page + ); + + return response()->json([ + 'ok' => true, + 'activities' => LoginActivityResource::collection($result['activities']), + 'pagination' => $result['pagination'], + ]); + } + + /** + * @return int|JsonResponse + */ + private function authenticatedUserIdOrUnauthorized(): int|JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + return $userId; + } +} diff --git a/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php b/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php index 3c1014d1..fd5aef14 100644 --- a/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php +++ b/app/Http/Controllers/Api/Utilities/PhoneFormatterController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api\Utilities; -use App\Http\Controllers\Api\BaseApiController; +use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Utilities\PhoneFormatRequest; use App\Http\Resources\Utilities\PhoneFormatResource; use App\Services\Phone\PhoneFormatterService; diff --git a/app/Http/Controllers/Api/Utilities/ProofreadController.php b/app/Http/Controllers/Api/Utilities/ProofreadController.php new file mode 100644 index 00000000..502bcaef --- /dev/null +++ b/app/Http/Controllers/Api/Utilities/ProofreadController.php @@ -0,0 +1,67 @@ +ip(); + if (RateLimiter::tooManyAttempts($key, 10)) { + return response()->json([ + 'ok' => false, + 'error' => 'Too many requests. Try again in a minute.', + 'csrfHash' => csrf_token(), + ], 429); + } + + RateLimiter::hit($key, 60); + + $text = (string) ($request->input('text') ?? ''); + if ($text === '' || mb_strlen($text) > 20000) { + return response()->json([ + 'ok' => false, + 'error' => 'Invalid text (empty or too long).', + 'csrfHash' => csrf_token(), + ], 422); + } + + try { + $resp = Http::timeout(10) + ->asForm() + ->post('https://api.languagetool.org/v2/check', [ + 'text' => $text, + 'language' => 'en-US', + ]); + + if (! $resp->successful()) { + return response()->json([ + 'ok' => false, + 'error' => 'Proofread service unavailable.', + 'csrfHash' => csrf_token(), + ], 502); + } + + return response()->json([ + 'ok' => true, + 'result' => $resp->json(), + 'csrfHash' => csrf_token(), + ]); + } catch (\Throwable $e) { + return response()->json([ + 'ok' => false, + 'error' => 'Proofread service unavailable.', + 'csrfHash' => csrf_token(), + ], 502); + } + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php old mode 100755 new mode 100644 index 9edfa9be..96e18c88 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,9 +2,10 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Routing\Controller as BaseController; class Controller extends BaseController { - // Base Laravel controller for future API controllers. + use AuthorizesRequests; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php old mode 100755 new mode 100644 diff --git a/app/Http/Middleware/EnsureBadgeScanLogsAccess.php b/app/Http/Middleware/EnsureBadgeScanLogsAccess.php new file mode 100644 index 00000000..e0f8112c --- /dev/null +++ b/app/Http/Middleware/EnsureBadgeScanLogsAccess.php @@ -0,0 +1,40 @@ +json(['message' => 'Unauthorized.'], 401); + } + + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + $allowed = [ + 'administrator', 'admin', 'principal', 'vice_principal', 'vice-principal', + 'teacher', 'teacher_assistant', + ]; + + foreach ($allowed as $role) { + if (in_array($role, $roles, true)) { + return $next($request); + } + } + + return response()->json(['message' => 'Forbidden.'], 403); + } +} diff --git a/app/Http/Middleware/EnsureClassProgressTeacherPortalAccess.php b/app/Http/Middleware/EnsureClassProgressTeacherPortalAccess.php new file mode 100644 index 00000000..b38a1e71 --- /dev/null +++ b/app/Http/Middleware/EnsureClassProgressTeacherPortalAccess.php @@ -0,0 +1,52 @@ +json(['message' => 'Unauthorized.'], 401); + } + + if ($this->allowed($user)) { + return $next($request); + } + + return response()->json(['message' => 'Forbidden.'], 403); + } + + private function allowed(User $user): bool + { + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) { + if (in_array($role, $roles, true)) { + return true; + } + } + + foreach (['teacher', 'teacher_assistant'] as $role) { + if (in_array($role, $roles, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Middleware/EnsureParentProgressAccess.php b/app/Http/Middleware/EnsureParentProgressAccess.php new file mode 100644 index 00000000..205a8950 --- /dev/null +++ b/app/Http/Middleware/EnsureParentProgressAccess.php @@ -0,0 +1,33 @@ +json(['message' => 'Unauthorized.'], 401); + } + + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + if (in_array('parent', $roles, true)) { + return $next($request); + } + + return response()->json(['message' => 'Forbidden.'], 403); + } +} diff --git a/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php b/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php new file mode 100644 index 00000000..339eee38 --- /dev/null +++ b/app/Http/Middleware/EnsurePrintRequestsAdminAccess.php @@ -0,0 +1,41 @@ +json(['message' => 'Unauthorized.'], 401); + } + + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach ( + ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] + as $allowed + ) { + if (in_array($allowed, $roles, true)) { + return $next($request); + } + } + + return response()->json(['message' => 'Forbidden.'], 403); + } +} diff --git a/app/Http/Middleware/EnsurePrintRequestsTeacherAccess.php b/app/Http/Middleware/EnsurePrintRequestsTeacherAccess.php new file mode 100644 index 00000000..12155a2d --- /dev/null +++ b/app/Http/Middleware/EnsurePrintRequestsTeacherAccess.php @@ -0,0 +1,35 @@ +json(['message' => 'Unauthorized.'], 401); + } + + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach (['teacher', 'teacher_assistant'] as $allowed) { + if (in_array($allowed, $roles, true)) { + return $next($request); + } + } + + return response()->json(['message' => 'Forbidden.'], 403); + } +} diff --git a/app/Http/Middleware/TeacherPortalAuthenticate.php b/app/Http/Middleware/TeacherPortalAuthenticate.php new file mode 100644 index 00000000..f8b23da2 --- /dev/null +++ b/app/Http/Middleware/TeacherPortalAuthenticate.php @@ -0,0 +1,48 @@ +check()) { + Auth::setUser(Auth::guard('web')->user()); + + return $next($request); + } + + $sanctumUser = Auth::guard('sanctum')->user(); + if ($sanctumUser) { + Auth::setUser($sanctumUser); + + return $next($request); + } + + $token = $request->bearerToken(); + if ($token !== null && $token !== '') { + try { + $jwtUser = JWTAuth::setToken($token)->authenticate(); + if ($jwtUser) { + Auth::setUser($jwtUser); + + return $next($request); + } + } catch (JWTException $e) { + // fall through + } + } + + return response()->json(['message' => 'Unauthorized.'], 401); + } +} diff --git a/app/Http/Requests/Admin/SaveCompetitionScoresRequest.php b/app/Http/Requests/Admin/SaveCompetitionScoresRequest.php new file mode 100644 index 00000000..1cb0f467 --- /dev/null +++ b/app/Http/Requests/Admin/SaveCompetitionScoresRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'class_section_id' => ['nullable', 'integer'], + 'scores' => ['required', 'array'], + 'scores.*' => ['nullable'], + ]; + } +} diff --git a/app/Http/Requests/Admin/StoreCompetitionWinnerRequest.php b/app/Http/Requests/Admin/StoreCompetitionWinnerRequest.php new file mode 100644 index 00000000..084f1f42 --- /dev/null +++ b/app/Http/Requests/Admin/StoreCompetitionWinnerRequest.php @@ -0,0 +1,32 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:3'], + 'class_section_id' => ['nullable', 'integer'], + 'semester' => ['nullable', 'string'], + 'school_year' => ['nullable', 'string'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date'], + 'winner_overrides' => ['sometimes', 'array'], + 'question_counts' => ['sometimes', 'array'], + 'prizes' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateCompetitionWinnerRequest.php b/app/Http/Requests/Admin/UpdateCompetitionWinnerRequest.php new file mode 100644 index 00000000..6146fa1e --- /dev/null +++ b/app/Http/Requests/Admin/UpdateCompetitionWinnerRequest.php @@ -0,0 +1,8 @@ +check(); } public function rules(): array diff --git a/app/Http/Requests/Auth/LoginSessionRequest.php b/app/Http/Requests/Auth/LoginSessionRequest.php new file mode 100644 index 00000000..8412f9f3 --- /dev/null +++ b/app/Http/Requests/Auth/LoginSessionRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'max:255'], + 'password' => ['required', 'string'], + 'redirect_to' => ['nullable', 'string', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/Auth/SetRoleSessionRequest.php b/app/Http/Requests/Auth/SetRoleSessionRequest.php new file mode 100644 index 00000000..1f6e0526 --- /dev/null +++ b/app/Http/Requests/Auth/SetRoleSessionRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'selected_role' => ['required', 'string', 'max:255'], + 'redirect_to' => ['nullable', 'string', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php b/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php index f410a9c5..f972858e 100644 --- a/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php +++ b/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php @@ -19,6 +19,7 @@ class ClassProgressStoreRequest extends ClassProgressFormRequest 'week_end' => ['nullable', 'date_format:Y-m-d'], 'support_needed' => ['nullable', 'string'], 'flags' => ['nullable', 'array'], + 'confirm_overwrite' => ['nullable', 'boolean'], ]; $sections = (array) config('progress.subject_sections', []); @@ -27,7 +28,7 @@ class ClassProgressStoreRequest extends ClassProgressFormRequest $mimeRule = 'mimes:' . implode(',', $mimes); foreach ($sections as $slug => $section) { - $rules["covered_{$slug}"] = ['required', 'string']; + $rules["covered_{$slug}"] = ['nullable', 'string']; $rules["homework_{$slug}"] = ['nullable', 'string']; $rules["unit_{$slug}"] = ['nullable', 'array']; $rules["unit_{$slug}.*"] = ['nullable', 'string', 'max:120']; diff --git a/app/Http/Requests/Parents/SignParentReportCardRequest.php b/app/Http/Requests/Parents/SignParentReportCardRequest.php new file mode 100644 index 00000000..73c934ac --- /dev/null +++ b/app/Http/Requests/Parents/SignParentReportCardRequest.php @@ -0,0 +1,20 @@ +check(); + } + + public function rules(): array + { + return [ + 'signed_name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Parents/StoreAuthorizedUserRequest.php b/app/Http/Requests/Parents/StoreAuthorizedUserRequest.php new file mode 100644 index 00000000..4e25fa85 --- /dev/null +++ b/app/Http/Requests/Parents/StoreAuthorizedUserRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + ]; + } +} diff --git a/app/Http/Requests/Parents/UpdateAuthorizedUserRequest.php b/app/Http/Requests/Parents/UpdateAuthorizedUserRequest.php new file mode 100644 index 00000000..e3215410 --- /dev/null +++ b/app/Http/Requests/Parents/UpdateAuthorizedUserRequest.php @@ -0,0 +1,30 @@ +has('email') && $this->input('email') === '') { + $this->merge(['email' => null]); + } + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'email' => ['sometimes', 'nullable', 'string', 'email'], + ]; + } +} diff --git a/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php b/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php index 2e40e7bf..cba8d0a3 100644 --- a/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php +++ b/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\ClassProgress; +use App\Services\ApplicationUrlService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -15,7 +16,7 @@ class ClassProgressAttachmentResource extends JsonResource 'id' => $id ? (int) $id : null, 'name' => $this['name'] ?? $this->original_name ?? null, 'file_path' => $this['file_path'] ?? $this->file_path ?? null, - 'download_url' => $id ? url("/api/v1/class-progress/attachments/{$id}") : null, + 'download_url' => $id ? app(ApplicationUrlService::class)->forClassProgressAttachment($id) : null, ]; } } diff --git a/app/Http/Resources/ClassProgress/ClassProgressReportResource.php b/app/Http/Resources/ClassProgress/ClassProgressReportResource.php index 6b11af36..5a14cfa8 100644 --- a/app/Http/Resources/ClassProgress/ClassProgressReportResource.php +++ b/app/Http/Resources/ClassProgress/ClassProgressReportResource.php @@ -14,8 +14,10 @@ class ClassProgressReportResource extends JsonResource return [ 'id' => $this->id, 'teacher_id' => $this->teacher_id, + 'teacher_name' => $this->teacher_name + ?? trim((string) ($this->teacher?->firstname ?? '').' '.(string) ($this->teacher?->lastname ?? '')), 'class_section_id' => $this->class_section_id, - 'class_section_name' => $this->classSection?->class_section_name, + 'class_section_name' => $this->class_section_name ?? $this->classSection?->class_section_name, 'week_start' => $this->week_start?->format('Y-m-d'), 'week_end' => $this->week_end?->format('Y-m-d'), 'subject' => $this->subject, diff --git a/app/Models/AdditionalCharge.php b/app/Models/AdditionalCharge.php old mode 100755 new mode 100644 index feb6791a..87962e1c --- a/app/Models/AdditionalCharge.php +++ b/app/Models/AdditionalCharge.php @@ -11,7 +11,7 @@ class AdditionalCharge extends BaseModel protected $table = 'additional_charges'; // CI useTimestamps = false - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'parent_id', @@ -25,8 +25,6 @@ class AdditionalCharge extends BaseModel 'due_date', 'status', 'created_by', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/AttendanceCommentTemplate.php b/app/Models/AttendanceCommentTemplate.php index b6e15036..d022bff1 100644 --- a/app/Models/AttendanceCommentTemplate.php +++ b/app/Models/AttendanceCommentTemplate.php @@ -9,7 +9,7 @@ class AttendanceCommentTemplate extends BaseModel protected $table = 'attendance_comment_template'; // CI: useTimestamps = false - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'min_score', diff --git a/app/Models/AttendanceData.php b/app/Models/AttendanceData.php old mode 100755 new mode 100644 index 12d20388..60b218cc --- a/app/Models/AttendanceData.php +++ b/app/Models/AttendanceData.php @@ -9,17 +9,17 @@ use Illuminate\Support\Facades\DB; class AttendanceData extends BaseModel { protected $table = 'attendance_data'; - public $timestamps = true; // uses created_at / updated_at protected $fillable = [ 'class_id', 'class_section_id', - 'student_id', 'school_id', + 'student_id', 'date', 'status', 'reason', + 'reported', 'is_reported', 'is_notified', 'semester', diff --git a/app/Models/AttendanceDay.php b/app/Models/AttendanceDay.php old mode 100755 new mode 100644 index ed4b5118..bd2a7919 --- a/app/Models/AttendanceDay.php +++ b/app/Models/AttendanceDay.php @@ -13,12 +13,16 @@ class AttendanceDay extends BaseModel // ✅ You are storing created_at / updated_at manually in CI (timestamps off there) // User didn't explicitly ask to auto-manage here, so we keep it OFF to match CI. // If you want auto-manage, tell me and I’ll flip it. - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'class_section_id', 'date', 'status', + 'draft', + 'submitted', + 'published', + 'finalized', 'submitted_by', 'submitted_at', 'published_by', diff --git a/app/Models/AttendanceEmailTemplate.php b/app/Models/AttendanceEmailTemplate.php index f3b23b7a..99496cb7 100644 --- a/app/Models/AttendanceEmailTemplate.php +++ b/app/Models/AttendanceEmailTemplate.php @@ -9,7 +9,7 @@ class AttendanceEmailTemplate extends BaseModel protected $table = 'email_templates'; // ✅ Laravel will auto-manage created_at / updated_at - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'code', diff --git a/app/Models/AttendanceRecord.php b/app/Models/AttendanceRecord.php old mode 100755 new mode 100644 diff --git a/app/Models/AttendanceTracking.php b/app/Models/AttendanceTracking.php old mode 100755 new mode 100644 index 9cc67a6c..556387cf --- a/app/Models/AttendanceTracking.php +++ b/app/Models/AttendanceTracking.php @@ -18,13 +18,11 @@ class AttendanceTracking extends BaseModel 'date', 'is_reported', 'reason', - 'note', 'is_notified', 'notif_counter', 'semester', 'school_year', - 'created_at', - 'updated_at', + 'note', ]; protected $casts = [ diff --git a/app/Models/AuthorizedUser.php b/app/Models/AuthorizedUser.php old mode 100755 new mode 100644 index d51ddb81..86781447 --- a/app/Models/AuthorizedUser.php +++ b/app/Models/AuthorizedUser.php @@ -17,7 +17,6 @@ class AuthorizedUser extends BaseModel 'firstname', 'lastname', 'phone_number', - 'gender', 'email', 'relation_to_user', 'token', diff --git a/app/Models/BadgePrintLog.php b/app/Models/BadgePrintLog.php old mode 100755 new mode 100644 diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php old mode 100755 new mode 100644 diff --git a/app/Models/CalendarEvent.php b/app/Models/CalendarEvent.php old mode 100755 new mode 100644 diff --git a/app/Models/CiDatabase.php b/app/Models/CiDatabase.php old mode 100755 new mode 100644 diff --git a/app/Models/CiModelBuilder.php b/app/Models/CiModelBuilder.php old mode 100755 new mode 100644 diff --git a/app/Models/CiQueryBuilder.php b/app/Models/CiQueryBuilder.php old mode 100755 new mode 100644 diff --git a/app/Models/CiQueryResult.php b/app/Models/CiQueryResult.php old mode 100755 new mode 100644 diff --git a/app/Models/ClassPrepAdjustment.php b/app/Models/ClassPrepAdjustment.php index cbc6d7eb..5c23de41 100644 --- a/app/Models/ClassPrepAdjustment.php +++ b/app/Models/ClassPrepAdjustment.php @@ -9,13 +9,12 @@ class ClassPrepAdjustment extends BaseModel protected $table = 'class_prep_adjustments'; // CI model didn't use timestamps; keep off unless your table has updated_at/created_at auto-managed. - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'class_section_id', 'item_name', 'adjustment', - 'adjustable', 'school_year', 'created_at', ]; diff --git a/app/Models/ClassPreparationLog.php b/app/Models/ClassPreparationLog.php index 789c53e4..1dd9230e 100644 --- a/app/Models/ClassPreparationLog.php +++ b/app/Models/ClassPreparationLog.php @@ -9,7 +9,7 @@ class ClassPreparationLog extends BaseModel protected $table = 'class_preparation_log'; // CI: useTimestamps = false - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'class_section_id', diff --git a/app/Models/ClassProgressReport.php b/app/Models/ClassProgressReport.php index a7de7ae0..531516a2 100644 --- a/app/Models/ClassProgressReport.php +++ b/app/Models/ClassProgressReport.php @@ -20,7 +20,6 @@ class ClassProgressReport extends BaseModel 'week_end', 'subject', 'unit_title', - 'materials', 'covered', 'homework', 'assessment', @@ -31,8 +30,6 @@ class ClassProgressReport extends BaseModel 'support_needed', 'flags_json', 'attachment_path', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/ClassSection.php b/app/Models/ClassSection.php old mode 100755 new mode 100644 index ab5d1e1d..59ff278c --- a/app/Models/ClassSection.php +++ b/app/Models/ClassSection.php @@ -21,8 +21,6 @@ class ClassSection extends BaseModel 'class_section_name', 'created_at', 'updated_at', - 'semester', - 'school_year', ]; protected $casts = [ diff --git a/app/Models/CommunicationLog.php b/app/Models/CommunicationLog.php index 7633c8c7..9170d2e6 100644 --- a/app/Models/CommunicationLog.php +++ b/app/Models/CommunicationLog.php @@ -9,7 +9,7 @@ class CommunicationLog extends BaseModel protected $table = 'communication_logs'; // CI model didn't enable timestamps; keep off unless your table has created_at/updated_at. - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'student_id', diff --git a/app/Models/Competition.php b/app/Models/Competition.php index 492ecc82..17fce517 100644 --- a/app/Models/Competition.php +++ b/app/Models/Competition.php @@ -26,13 +26,10 @@ class Competition extends BaseModel 'prize_per_point', 'is_published', 'published_at', - 'created_by', - 'created_at', - 'updated_at', - 'deleted_at', 'is_locked', 'locked_at', 'locked_by', + 'created_by', ]; protected $casts = [ diff --git a/app/Models/CompetitionClassWinner.php b/app/Models/CompetitionClassWinner.php index f9f58550..e5b4878e 100644 --- a/app/Models/CompetitionClassWinner.php +++ b/app/Models/CompetitionClassWinner.php @@ -15,8 +15,6 @@ class CompetitionClassWinner extends BaseModel 'competition_id', 'class_section_id', 'winners_override', - 'created_at', - 'updated_at', 'question_count', 'prize_1', 'prize_2', @@ -30,8 +28,7 @@ class CompetitionClassWinner extends BaseModel 'competition_id' => 'integer', 'class_section_id' => 'integer', 'question_count' => 'integer', - // If winners_override is JSON in DB, cast to array: - 'winners_override' => 'array', + 'winners_override' => 'integer', ]; /* Optional relationships */ diff --git a/app/Models/CompetitionScore.php b/app/Models/CompetitionScore.php index b5a21809..54d0fd96 100644 --- a/app/Models/CompetitionScore.php +++ b/app/Models/CompetitionScore.php @@ -17,8 +17,6 @@ class CompetitionScore extends BaseModel 'class_section_id', 'score', 'notes', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/Configuration.php b/app/Models/Configuration.php old mode 100755 new mode 100644 index d512bc2a..e21e8d96 --- a/app/Models/Configuration.php +++ b/app/Models/Configuration.php @@ -10,7 +10,7 @@ class Configuration extends BaseModel protected $table = 'configuration'; // CI model didn't use timestamps - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'config_key', diff --git a/app/Models/ContactUs.php b/app/Models/ContactUs.php old mode 100755 new mode 100644 index ca731e72..bfca4103 --- a/app/Models/ContactUs.php +++ b/app/Models/ContactUs.php @@ -16,10 +16,10 @@ class ContactUs extends BaseModel 'reciever_id', 'subject', 'message', - 'created_at', - 'updated_at', 'semester', 'school_year', + 'created_at', + 'updated_at', ]; protected $casts = [ diff --git a/app/Models/CurrentIncident.php b/app/Models/CurrentIncident.php index 736a79fc..4cf08894 100644 --- a/app/Models/CurrentIncident.php +++ b/app/Models/CurrentIncident.php @@ -7,7 +7,6 @@ use App\Models\BaseModel; class CurrentIncident extends BaseModel { protected $table = 'current_incident'; - public $timestamps = true; protected $fillable = [ diff --git a/app/Models/DiscountUsage.php b/app/Models/DiscountUsage.php old mode 100755 new mode 100644 index 958b69b7..a6374ae1 --- a/app/Models/DiscountUsage.php +++ b/app/Models/DiscountUsage.php @@ -15,15 +15,12 @@ class DiscountUsage extends BaseModel protected $fillable = [ 'voucher_id', 'invoice_id', - 'parent_id', 'discount_amount', - 'description', 'school_year', - 'updated_by', - 'used_at', - 'created_at', - 'updated_at', 'semester', + 'updated_by', + 'parent_id', + 'used_at', ]; protected $casts = [ diff --git a/app/Models/DiscountVoucher.php b/app/Models/DiscountVoucher.php old mode 100755 new mode 100644 index 8758ab30..ecdc0c9f --- a/app/Models/DiscountVoucher.php +++ b/app/Models/DiscountVoucher.php @@ -16,17 +16,17 @@ class DiscountVoucher extends BaseModel protected $fillable = [ 'code', 'discount_type', - 'description', 'discount_value', 'max_uses', 'times_used', 'valid_from', 'valid_until', + 'semester', + 'school_year', 'is_active', + 'description', 'created_at', 'updated_at', - 'school_year', - 'semester', ]; protected $casts = [ diff --git a/app/Models/EarlyDismissalSignature.php b/app/Models/EarlyDismissalSignature.php index a2013894..3c014d54 100644 --- a/app/Models/EarlyDismissalSignature.php +++ b/app/Models/EarlyDismissalSignature.php @@ -20,8 +20,6 @@ class EarlyDismissalSignature extends BaseModel 'mime_type', 'file_size', 'uploaded_by', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/EmailTemplate.php b/app/Models/EmailTemplate.php old mode 100755 new mode 100644 index b0978481..8b5733f5 --- a/app/Models/EmailTemplate.php +++ b/app/Models/EmailTemplate.php @@ -9,16 +9,14 @@ class EmailTemplate extends BaseModel protected $table = 'email_templates'; // CI model didn't specify timestamps; keep off unless your table has created_at/updated_at. - public $timestamps = false; + public $timestamps = true; protected $fillable = [ - 'code', - 'variant', + 'template_key', + 'name', 'subject', - 'body_html', + 'body', 'is_active', - 'updated_by', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/EmergencyContact.php b/app/Models/EmergencyContact.php old mode 100755 new mode 100644 index 29ec8092..d9614690 --- a/app/Models/EmergencyContact.php +++ b/app/Models/EmergencyContact.php @@ -12,8 +12,8 @@ class EmergencyContact extends BaseModel public $timestamps = true; protected $fillable = [ - 'emergency_contact_name', 'parent_id', + 'emergency_contact_name', 'cellphone', 'email', 'relation', diff --git a/app/Models/Enrollment.php b/app/Models/Enrollment.php old mode 100755 new mode 100644 index c487062f..0286ff3c --- a/app/Models/Enrollment.php +++ b/app/Models/Enrollment.php @@ -16,13 +16,13 @@ class Enrollment extends BaseModel 'student_id', 'class_section_id', 'parent_id', + 'school_year', 'enrollment_date', 'enrollment_status', 'withdrawal_date', 'is_withdrawn', 'admission_status', 'semester', - 'school_year', 'created_at', 'updated_at', ]; diff --git a/app/Models/Event.php b/app/Models/Event.php old mode 100755 new mode 100644 diff --git a/app/Models/EventCharges.php b/app/Models/EventCharges.php old mode 100755 new mode 100644 index 60dfae27..654b6d6e --- a/app/Models/EventCharges.php +++ b/app/Models/EventCharges.php @@ -18,8 +18,20 @@ class EventCharges extends BaseModel 'student_id', 'participation', 'charged', + 'waiver_signed', + 'event_paid', + 'event_payment_id', + 'class_section_id', + 'external_firstname', + 'external_lastname', + 'external_note', + 'external_parent_firstname', + 'external_parent_lastname', + 'external_parent_phone', + 'external_parent_email', 'semester', 'school_year', + 'created_by', 'updated_by', 'created_at', 'updated_at', diff --git a/app/Models/Exam.php b/app/Models/Exam.php index c1e85ce2..4bdea630 100644 --- a/app/Models/Exam.php +++ b/app/Models/Exam.php @@ -15,12 +15,12 @@ class Exam extends BaseModel * * We'll keep auto created_at, no updated_at: */ - public $timestamps = false; + public $timestamps = true; const UPDATED_AT = null; // ✅ no updated_at column protected $fillable = [ 'student_id', - 'student_school_id', + 'school_id', 'class_section_id', 'exam_name', 'created_at', diff --git a/app/Models/ExamDraft.php b/app/Models/ExamDraft.php index 259a96c1..a98c94a5 100644 --- a/app/Models/ExamDraft.php +++ b/app/Models/ExamDraft.php @@ -13,16 +13,26 @@ class ExamDraft extends BaseModel protected $fillable = [ 'teacher_id', + 'author_id', 'class_section_id', 'semester', 'school_year', 'exam_type', 'draft_title', + 'author_comment', 'description', 'teacher_file', 'teacher_filename', + 'author_file', + 'author_filename', 'status', + 'acceptance_type', + 'review_revision', + 'reviewer_id', 'admin_id', + 'is_legacy', + 'reviewer_comment', + 'reviewer_comments', 'admin_comments', 'reviewed_at', 'final_file', @@ -30,9 +40,6 @@ class ExamDraft extends BaseModel 'final_pdf_file', 'version', 'previous_draft_id', - 'is_legacy', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/Expense.php b/app/Models/Expense.php old mode 100755 new mode 100644 index d623b8b8..280b8269 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -18,18 +18,16 @@ class Expense extends BaseModel 'receipt_path', 'description', 'retailor', - 'purchased_by', 'date_of_purchase', + 'purchased_by', 'added_by', - 'created_at', - 'updated_at', 'school_year', 'semester', 'status', 'status_reason', - 'updated_by', - 'approved_by', 'reimbursement_id', + 'approved_by', + 'updated_by', ]; protected $casts = [ diff --git a/app/Models/Family.php b/app/Models/Family.php index 1feb7595..88f8fb76 100644 --- a/app/Models/Family.php +++ b/app/Models/Family.php @@ -24,8 +24,6 @@ class Family extends BaseModel 'preferred_lang', 'preferred_contact_method', 'is_active', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/FamilyCommPref.php b/app/Models/FamilyCommPref.php index 43971970..0d3845c0 100644 --- a/app/Models/FamilyCommPref.php +++ b/app/Models/FamilyCommPref.php @@ -17,8 +17,6 @@ class FamilyCommPref extends BaseModel 'via_email', 'via_sms', 'cc_all_guardians', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/FamilyGuardian.php b/app/Models/FamilyGuardian.php index cadc2550..88969c61 100644 --- a/app/Models/FamilyGuardian.php +++ b/app/Models/FamilyGuardian.php @@ -19,8 +19,6 @@ class FamilyGuardian extends BaseModel 'receive_emails', 'receive_sms', 'custody_notes', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/FamilyStudent.php b/app/Models/FamilyStudent.php old mode 100755 new mode 100644 index c5ec8265..cf96e7f3 --- a/app/Models/FamilyStudent.php +++ b/app/Models/FamilyStudent.php @@ -17,8 +17,6 @@ class FamilyStudent extends BaseModel 'student_id', 'is_primary_home', 'notes', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/FinalExam.php b/app/Models/FinalExam.php old mode 100755 new mode 100644 index fe8378e6..2f4aeec9 --- a/app/Models/FinalExam.php +++ b/app/Models/FinalExam.php @@ -12,7 +12,7 @@ class FinalExam extends BaseModel * CI: useTimestamps = false (even though table has created_at/updated_at fields). * So we keep timestamps OFF and you can set created_at/updated_at manually if needed. */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'student_id', @@ -20,6 +20,7 @@ class FinalExam extends BaseModel 'class_section_id', 'updated_by', 'score', + 'comment', 'semester', 'school_year', 'created_at', diff --git a/app/Models/FinalScore.php b/app/Models/FinalScore.php old mode 100755 new mode 100644 index 2ce4cbee..bafc2418 --- a/app/Models/FinalScore.php +++ b/app/Models/FinalScore.php @@ -16,14 +16,14 @@ class FinalScore extends BaseModel 'student_id', 'school_id', 'class_section_id', - 'updated_by', + 'teacher_id', 'score', 'score_letter', 'comment', + 'semester', 'school_year', 'created_at', 'updated_at', - 'semester', ]; protected $casts = [ diff --git a/app/Models/Homework.php b/app/Models/Homework.php old mode 100755 new mode 100644 index 57b2ecc1..c4bf4271 --- a/app/Models/Homework.php +++ b/app/Models/Homework.php @@ -13,7 +13,7 @@ class Homework extends BaseModel * CI: useTimestamps = false (even though created_at/updated_at columns exist). * Keep it OFF to match behavior. If you want Laravel to auto-manage, tell me. */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'student_id', diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php old mode 100755 new mode 100644 index 77aab452..d46b4be7 --- a/app/Models/InventoryItem.php +++ b/app/Models/InventoryItem.php @@ -17,10 +17,6 @@ class InventoryItem extends BaseModel 'name', 'description', 'quantity', - 'good_qty', - 'needs_repair_qty', - 'need_replace_qty', - 'cannot_find_qty', 'unit', 'condition', 'isbn', @@ -30,8 +26,10 @@ class InventoryItem extends BaseModel 'semester', 'school_year', 'updated_by', - 'created_at', - 'updated_at', + 'good_qty', + 'needs_repair_qty', + 'need_replace_qty', + 'cannot_find_qty', ]; protected $casts = [ diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index a2afd3bc..263c58a0 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -23,8 +23,6 @@ class InventoryMovement extends BaseModel 'teacher_id', 'student_id', 'class_section_id', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php old mode 100755 new mode 100644 index 7deb55e9..ceae0d96 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -14,12 +14,14 @@ class Invoice extends BaseModel * CI: useTimestamps = false because DB defaults/triggers manage created_at/updated_at. * In Laravel, keep timestamps OFF and let DB handle them. */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'parent_id', 'invoice_number', 'total_amount', + 'school_year', + 'semester', 'balance', 'paid_amount', 'has_discount', @@ -27,11 +29,9 @@ class Invoice extends BaseModel 'due_date', 'status', 'description', - 'school_year', 'created_at', 'updated_at', 'updated_by', - 'semester', ]; protected $casts = [ diff --git a/app/Models/InvoiceEvent.php b/app/Models/InvoiceEvent.php old mode 100755 new mode 100644 diff --git a/app/Models/InvoiceStudentList.php b/app/Models/InvoiceStudentList.php old mode 100755 new mode 100644 index e4ca22f3..fd565640 --- a/app/Models/InvoiceStudentList.php +++ b/app/Models/InvoiceStudentList.php @@ -19,9 +19,9 @@ class InvoiceStudentList extends BaseModel 'school_id', 'enrolled', 'school_year', + 'semester', 'created_at', 'updated_at', - 'semester', ]; protected $casts = [ diff --git a/app/Models/IpAttempt.php b/app/Models/IpAttempt.php old mode 100755 new mode 100644 index 64f0655e..fc7e3616 --- a/app/Models/IpAttempt.php +++ b/app/Models/IpAttempt.php @@ -15,11 +15,9 @@ class IpAttempt extends BaseModel 'ip_address', 'attempts', 'last_attempt_at', - 'blocked_until', - 'created_at', - 'updated_at', - 'school_year', 'semester', + 'school_year', + 'blocked_until', ]; protected $casts = [ diff --git a/app/Models/LateSlipLog.php b/app/Models/LateSlipLog.php old mode 100755 new mode 100644 diff --git a/app/Models/LoginActivity.php b/app/Models/LoginActivity.php old mode 100755 new mode 100644 index 7a3ae2bc..b4304e46 --- a/app/Models/LoginActivity.php +++ b/app/Models/LoginActivity.php @@ -18,10 +18,10 @@ class LoginActivity extends BaseModel 'logout_time', 'ip_address', 'user_agent', + 'school_year', + 'semester', 'created_at', 'updated_at', - 'semester', - 'school_year', ]; protected $casts = [ diff --git a/app/Models/ManualPayment.php b/app/Models/ManualPayment.php old mode 100755 new mode 100644 index 431be574..a2a43fbf --- a/app/Models/ManualPayment.php +++ b/app/Models/ManualPayment.php @@ -16,11 +16,10 @@ class ManualPayment extends BaseModel 'amount', 'payment_method', 'reference', - 'discount_number', 'proof_path', - 'created_at', - 'school_year', 'semester', + 'school_year', + 'created_at', ]; protected $casts = [ diff --git a/app/Models/MidtermExam.php b/app/Models/MidtermExam.php index 61f1b203..20670868 100644 --- a/app/Models/MidtermExam.php +++ b/app/Models/MidtermExam.php @@ -12,7 +12,7 @@ class MidtermExam extends BaseModel * CI: useTimestamps = false (even though created_at/updated_at columns exist). * Keep it OFF to match behavior (you can still store created_at/updated_at manually). */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'student_id', diff --git a/app/Models/NavItem.php b/app/Models/NavItem.php index c23f0452..9126522e 100644 --- a/app/Models/NavItem.php +++ b/app/Models/NavItem.php @@ -11,7 +11,6 @@ class NavItem extends BaseModel // ✅ CI: timestamps + soft deletes use SoftDeletes; - public $timestamps = true; protected $fillable = [ @@ -22,9 +21,6 @@ class NavItem extends BaseModel 'target', 'sort_order', 'is_enabled', - 'created_at', - 'updated_at', - 'deleted_at', ]; protected $casts = [ diff --git a/app/Models/Notification.php b/app/Models/Notification.php index 53e64855..9011dbc7 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -11,7 +11,6 @@ class Notification extends BaseModel // ✅ CI: soft deletes + timestamps use SoftDeletes; - public $timestamps = true; protected $fillable = [ @@ -23,14 +22,9 @@ class Notification extends BaseModel 'status', 'action_url', 'attachment_path', - 'scheduled_at', - 'sent_at', - 'expires_at', - 'created_at', - 'updated_at', - 'deleted_at', - 'school_year', 'semester', + 'school_year', + 'scheduled_at', ]; protected $casts = [ diff --git a/app/Models/ParentAttendanceReport.php b/app/Models/ParentAttendanceReport.php index cc176a5a..d03e2ee9 100644 --- a/app/Models/ParentAttendanceReport.php +++ b/app/Models/ParentAttendanceReport.php @@ -24,8 +24,6 @@ class ParentAttendanceReport extends BaseModel 'semester', 'school_year', 'status', - 'created_at', - 'updated_at', ]; protected $casts = [ @@ -61,10 +59,15 @@ class ParentAttendanceReport extends BaseModel ?string $startDate = null, ?string $endDate = null, ?string $schoolYear = null, - ?string $semester = null + ?string $semester = null, + ?int $parentId = null, ): array { $q = DB::table('parent_attendance_reports as par'); + if ($parentId !== null && $parentId > 0) { + $q->where('par.parent_id', $parentId); + } + if (!empty($startDate)) { $q->where('par.report_date', '>=', $startDate); } diff --git a/app/Models/ParentMeetingSchedule.php b/app/Models/ParentMeetingSchedule.php index 290310da..06a3e601 100644 --- a/app/Models/ParentMeetingSchedule.php +++ b/app/Models/ParentMeetingSchedule.php @@ -24,8 +24,6 @@ class ParentMeetingSchedule extends BaseModel 'school_year', 'status', 'created_by', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/ParentModel.php b/app/Models/ParentModel.php index 7b18ed8e..ec5c1895 100644 --- a/app/Models/ParentModel.php +++ b/app/Models/ParentModel.php @@ -18,11 +18,8 @@ class ParentModel extends BaseModel 'secondparent_email', 'secondparent_phone', 'firstparent_id', - 'secondparent_id', 'semester', 'school_year', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/ParentNotification.php b/app/Models/ParentNotification.php index c2188cab..d595e484 100644 --- a/app/Models/ParentNotification.php +++ b/app/Models/ParentNotification.php @@ -22,8 +22,6 @@ class ParentNotification extends BaseModel 'response', 'semester', 'school_year', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/Participation.php b/app/Models/Participation.php index dab4445c..5fee86c1 100644 --- a/app/Models/Participation.php +++ b/app/Models/Participation.php @@ -12,7 +12,7 @@ class Participation extends BaseModel * CI: useTimestamps = false (even though created_at/updated_at columns exist). * Keep OFF to match behavior (you can still set created_at/updated_at manually). */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'student_id', diff --git a/app/Models/PasswordReset.php b/app/Models/PasswordReset.php index bf2a144f..4469abe1 100644 --- a/app/Models/PasswordReset.php +++ b/app/Models/PasswordReset.php @@ -20,7 +20,7 @@ class PasswordReset extends BaseModel * This will auto-set expires_at whenever you call save()/update(). * (Only do this if that’s truly what you want.) */ - public $timestamps = false; + public $timestamps = true; const CREATED_AT = 'created_at'; const UPDATED_AT = 'expires_at'; diff --git a/app/Models/PasswordResetRequest.php b/app/Models/PasswordResetRequest.php index 9ab2137a..30eb945a 100644 --- a/app/Models/PasswordResetRequest.php +++ b/app/Models/PasswordResetRequest.php @@ -9,7 +9,7 @@ class PasswordResetRequest extends BaseModel protected $table = 'password_reset_requests'; // CI: timestamps off - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'ip_address', diff --git a/app/Models/PayPalPayment.php b/app/Models/PayPalPayment.php index 3d37295b..f69263fd 100644 --- a/app/Models/PayPalPayment.php +++ b/app/Models/PayPalPayment.php @@ -12,7 +12,7 @@ class PayPalPayment extends BaseModel * CI: created_at is managed, updated_at is NOT used. * In Laravel: enable timestamps but disable UPDATED_AT. */ - public $timestamps = false; + public $timestamps = true; const UPDATED_AT = null; protected $fillable = [ @@ -31,8 +31,9 @@ class PayPalPayment extends BaseModel 'summary', 'raw_payload', 'synced', + 'semester', + 'school_year', 'sync_attempts', - 'created_at', ]; protected $casts = [ diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 38783bb1..0e0279b1 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -13,7 +13,7 @@ class Payment extends BaseModel * CI: useTimestamps = false because DB handles created_at/updated_at (defaults/triggers). * Keep OFF in Laravel too. */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'parent_id', @@ -27,12 +27,10 @@ class Payment extends BaseModel 'check_number', 'payment_method', 'payment_date', - 'semester', 'school_year', + 'semester', 'status', 'updated_by', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/PaymentError.php b/app/Models/PaymentError.php index d6919654..63570d54 100644 --- a/app/Models/PaymentError.php +++ b/app/Models/PaymentError.php @@ -19,10 +19,10 @@ class PaymentError extends BaseModel 'wrong_payment_method', 'wrong_check_file', 'error_note', + 'semester', + 'school_year', 'logged_at', 'logged_by', - 'school_year', - 'semester', ]; protected $casts = [ diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php index 67e67e95..2e79d976 100644 --- a/app/Models/PaymentTransaction.php +++ b/app/Models/PaymentTransaction.php @@ -10,7 +10,7 @@ class PaymentTransaction extends BaseModel protected $table = 'payment_transactions'; // ✅ CI: useTimestamps = true (created_at/updated_at) - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'transaction_id', @@ -21,9 +21,11 @@ class PaymentTransaction extends BaseModel 'payment_status', 'transaction_fee', 'payment_reference', - 'is_full_payment', - 'school_year', 'semester', + 'school_year', + 'is_full_payment', + 'created_at', + 'updated_at', ]; protected $casts = [ diff --git a/app/Models/PaypalTransaction.php b/app/Models/PaypalTransaction.php index a51b6ea1..e663ff4b 100644 --- a/app/Models/PaypalTransaction.php +++ b/app/Models/PaypalTransaction.php @@ -23,11 +23,9 @@ class PaypalTransaction extends BaseModel 'status', 'payment_method', 'transaction_fee', - 'raw_data', - 'created_at', - 'updated_at', - 'school_year', 'semester', + 'school_year', + 'raw_data', ]; protected $casts = [ diff --git a/app/Models/Preferences.php b/app/Models/Preferences.php index e5d41c7e..4af13e12 100644 --- a/app/Models/Preferences.php +++ b/app/Models/Preferences.php @@ -13,15 +13,22 @@ class Preferences extends BaseModel protected $fillable = [ 'user_id', - 'notification_email', - 'notification_sms', + 'receive_email_notifications', + 'receive_sms_notifications', 'theme', 'language', + 'timezone', 'style_color', 'menu_color', + 'custom', 'menu_custom_bg', 'menu_custom_text', 'menu_custom_mode', + 'receive_push_notifications', + 'daily_summary_email', + 'privacy_mode', + 'marketing_emails', + 'account_activity_alerts', 'created_at', 'updated_at', ]; diff --git a/app/Models/PrintRequest.php b/app/Models/PrintRequest.php index 8823fab6..524bd602 100644 --- a/app/Models/PrintRequest.php +++ b/app/Models/PrintRequest.php @@ -21,8 +21,6 @@ class PrintRequest extends BaseModel 'required_by', 'pickup_method', 'status', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/PromotionQueue.php b/app/Models/PromotionQueue.php index 4a66c34e..4df0381a 100644 --- a/app/Models/PromotionQueue.php +++ b/app/Models/PromotionQueue.php @@ -22,7 +22,6 @@ class PromotionQueue extends BaseModel 'updated_at', 'updated_by', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/PurchaseOrder.php b/app/Models/PurchaseOrder.php index 76abe688..c6d3f761 100644 --- a/app/Models/PurchaseOrder.php +++ b/app/Models/PurchaseOrder.php @@ -25,7 +25,6 @@ class PurchaseOrder extends BaseModel 'total', 'notes', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/PurchaseOrderItem.php b/app/Models/PurchaseOrderItem.php index 6082fef2..5bca3a02 100644 --- a/app/Models/PurchaseOrderItem.php +++ b/app/Models/PurchaseOrderItem.php @@ -17,7 +17,6 @@ class PurchaseOrderItem extends BaseModel 'received_qty', 'unit_cost', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php index 7e615463..353e00fe 100644 --- a/app/Models/Quiz.php +++ b/app/Models/Quiz.php @@ -8,8 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Quiz extends BaseModel { protected $table = 'quiz'; - - public $timestamps = true; + public $timestamps = false; protected $fillable = [ 'student_id', diff --git a/app/Models/Refund.php b/app/Models/Refund.php index dd467bcc..092debc0 100644 --- a/app/Models/Refund.php +++ b/app/Models/Refund.php @@ -23,16 +23,14 @@ class Refund extends BaseModel 'refund_paid_amount', 'request', 'note', + 'semester', + 'school_year', 'approved_by', 'updated_by', 'refund_method', 'check_nbr', 'check_file', - 'created_at', - 'updated_at', - 'semester', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/Reimbursement.php b/app/Models/Reimbursement.php index 7f0af59b..d69e6a5c 100644 --- a/app/Models/Reimbursement.php +++ b/app/Models/Reimbursement.php @@ -11,16 +11,14 @@ class Reimbursement extends BaseModel protected $table = 'reimbursements'; protected $fillable = [ - 'expense_id', 'amount', 'reimbursed_to', 'approved_by', 'receipt_path', 'description', 'status', + 'expense_id', 'added_by', - 'created_at', - 'updated_at', 'school_year', 'semester', 'check_number', diff --git a/app/Models/ReimbursementBatch.php b/app/Models/ReimbursementBatch.php index 06b92cb4..02796a29 100644 --- a/app/Models/ReimbursementBatch.php +++ b/app/Models/ReimbursementBatch.php @@ -18,15 +18,15 @@ class ReimbursementBatch extends BaseModel protected $fillable = [ 'title', - 'yearly_batch_number', 'status', - 'school_year', - 'semester', 'created_by', 'closed_by', 'opened_at', 'closed_at', 'notes', + 'school_year', + 'semester', + 'yearly_batch_number', ]; protected $casts = [ diff --git a/app/Models/ReportCardAcknowledgement.php b/app/Models/ReportCardAcknowledgement.php index 9cc76d24..d7f3ad90 100644 --- a/app/Models/ReportCardAcknowledgement.php +++ b/app/Models/ReportCardAcknowledgement.php @@ -22,7 +22,6 @@ class ReportCardAcknowledgement extends BaseModel 'created_at', 'updated_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/Role.php b/app/Models/Role.php index 2112cded..e7b485a8 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -19,7 +19,6 @@ class Role extends BaseModel 'created_at', 'updated_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/RoleNavItem.php b/app/Models/RoleNavItem.php index 598d04c9..41f16cff 100644 --- a/app/Models/RoleNavItem.php +++ b/app/Models/RoleNavItem.php @@ -9,14 +9,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class RoleNavItem extends BaseModel { protected $table = 'role_nav_items'; - public $timestamps = true; protected $fillable = [ 'role_id', 'nav_item_id', - 'created_at', - 'updated_at', ]; protected $casts = [ diff --git a/app/Models/RolePermission.php b/app/Models/RolePermission.php index 8d61aa69..87b2d7de 100644 --- a/app/Models/RolePermission.php +++ b/app/Models/RolePermission.php @@ -22,7 +22,6 @@ class RolePermission extends BaseModel 'created_at', 'updated_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/ScoreComment.php b/app/Models/ScoreComment.php index ff2b7769..9b5c0d54 100644 --- a/app/Models/ScoreComment.php +++ b/app/Models/ScoreComment.php @@ -14,7 +14,7 @@ class ScoreComment extends BaseModel * CI: timestamps disabled (created_at handled by DB default). * Keep disabled in Laravel too. */ - public $timestamps = false; + public $timestamps = true; /** * If your DB uses created_at as default timestamp and you want Laravel diff --git a/app/Models/Section.php b/app/Models/Section.php index 8e35d673..13696f97 100644 --- a/app/Models/Section.php +++ b/app/Models/Section.php @@ -13,11 +13,11 @@ class Section extends BaseModel protected $fillable = [ 'section_name', 'description', + 'created_at', 'updated_at', 'updated_by', ]; - - public $timestamps = false; + public $timestamps = true; protected $casts = [ 'updated_by' => 'integer', diff --git a/app/Models/SemesterScore.php b/app/Models/SemesterScore.php index ca93d266..7f44832d 100644 --- a/app/Models/SemesterScore.php +++ b/app/Models/SemesterScore.php @@ -27,8 +27,6 @@ class SemesterScore extends BaseModel 'semester_score', 'semester', 'school_year', - 'created_at', - 'updated_at', ]; /** diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 2314aea4..e7441cc2 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -17,7 +17,6 @@ class Setting extends BaseModel 'created_at', 'updated_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/Staff.php b/app/Models/Staff.php index f107dd5f..5aa9c819 100644 --- a/app/Models/Staff.php +++ b/app/Models/Staff.php @@ -13,19 +13,20 @@ class Staff extends BaseModel /** * CI: timestamps managed manually (created_at/updated_at set by caller/controller) */ - public $timestamps = true; + public $timestamps = false; protected $fillable = [ + 'user_id', 'firstname', 'lastname', 'email', 'phone', 'role_name', - 'school_year', 'active_role', + 'status', + 'school_year', 'created_at', 'updated_at', - 'user_id', ]; protected $casts = [ diff --git a/app/Models/StaffAttendance.php b/app/Models/StaffAttendance.php index 081513b7..deefa120 100644 --- a/app/Models/StaffAttendance.php +++ b/app/Models/StaffAttendance.php @@ -10,7 +10,6 @@ use Illuminate\Support\Facades\DB; class StaffAttendance extends BaseModel { protected $table = 'staff_attendance'; - public $timestamps = true; /** @@ -28,6 +27,9 @@ class StaffAttendance extends BaseModel 'semester', 'school_year', 'status', + 'present', + 'absent', + 'late', 'reason', 'created_at', 'updated_at', diff --git a/app/Models/Student.php b/app/Models/Student.php index 5330c369..31fb9924 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -26,17 +26,16 @@ class Student extends BaseModel 'dob', 'age', 'gender', - 'is_active', 'registration_grade', - 'is_new', 'photo_consent', + 'is_new', 'parent_id', + 'school_year', 'registration_date', 'tuition_paid', - 'rfid_tag', - 'semester', 'year_of_registration', - 'school_year', + 'rfid_tag', + 'is_active', ]; protected $casts = [ diff --git a/app/Models/StudentAllergy.php b/app/Models/StudentAllergy.php index 5b71be17..d0d530ab 100644 --- a/app/Models/StudentAllergy.php +++ b/app/Models/StudentAllergy.php @@ -13,7 +13,7 @@ class StudentAllergy extends BaseModel /** * CI: timestamps disabled */ - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'student_id', diff --git a/app/Models/StudentClass.php b/app/Models/StudentClass.php index b05f7897..ccc75363 100644 --- a/app/Models/StudentClass.php +++ b/app/Models/StudentClass.php @@ -13,16 +13,15 @@ class StudentClass extends BaseModel protected $fillable = [ 'student_id', + 'school_id', 'class_section_id', - 'is_event_only', - 'semester', 'school_year', + 'is_event_only', 'description', - 'created_at', - 'updated_at', 'updated_by', + 'updated_at', + 'created_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/StudentMedicalCondition.php b/app/Models/StudentMedicalCondition.php index 53fb38c4..466b0fa1 100644 --- a/app/Models/StudentMedicalCondition.php +++ b/app/Models/StudentMedicalCondition.php @@ -13,7 +13,7 @@ class StudentMedicalCondition extends BaseModel /** * CI: timestamps disabled */ - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'student_id', diff --git a/app/Models/SubjectCurriculum.php b/app/Models/SubjectCurriculum.php index 49eeb9ed..fb2b168a 100644 --- a/app/Models/SubjectCurriculum.php +++ b/app/Models/SubjectCurriculum.php @@ -19,7 +19,6 @@ class SubjectCurriculum extends BaseModel 'created_at', 'updated_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php index 3bc40183..47980b7e 100644 --- a/app/Models/Supplier.php +++ b/app/Models/Supplier.php @@ -7,7 +7,6 @@ use App\Models\BaseModel; class Supplier extends BaseModel { protected $table = 'suppliers'; - public $timestamps = true; protected $fillable = [ diff --git a/app/Models/SupplyCategory.php b/app/Models/SupplyCategory.php index 16f13a3a..8f422b09 100644 --- a/app/Models/SupplyCategory.php +++ b/app/Models/SupplyCategory.php @@ -7,7 +7,6 @@ use App\Models\BaseModel; class SupplyCategory extends BaseModel { protected $table = 'supply_categories'; - public $timestamps = true; protected $fillable = [ diff --git a/app/Models/SupportRequest.php b/app/Models/SupportRequest.php index 792488a3..7c4bb6c6 100644 --- a/app/Models/SupportRequest.php +++ b/app/Models/SupportRequest.php @@ -7,7 +7,6 @@ use App\Models\BaseModel; class SupportRequest extends BaseModel { protected $table = 'support_requests'; - public $timestamps = true; protected $fillable = [ diff --git a/app/Models/TeacherClass.php b/app/Models/TeacherClass.php index 05ee477d..17148f75 100644 --- a/app/Models/TeacherClass.php +++ b/app/Models/TeacherClass.php @@ -12,17 +12,14 @@ class TeacherClass extends BaseModel protected $table = 'teacher_class'; protected $fillable = [ - 'class_section_id', 'teacher_id', - 'position', - 'semester', + 'class_section_id', 'school_year', - 'description', - 'created_at', - 'updated_at', + 'position', 'updated_by', + 'updated_at', + 'created_at', ]; - public $timestamps = true; protected $casts = [ diff --git a/app/Models/User.php b/app/Models/User.php index 3c4a3858..a56872ba 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -18,12 +18,15 @@ class User extends Authenticatable implements JWTSubject use HasApiTokens; use HasFactory; + public $timestamps = true; + protected $table = 'users'; protected $fillable = [ - 'school_id', - 'firstname', + 'password', + 'account_id', 'lastname', + 'firstname', 'gender', 'cellphone', 'email', @@ -33,20 +36,19 @@ class User extends Authenticatable implements JWTSubject 'state', 'zip', 'accept_school_policy', - 'is_verified', - 'status', - 'is_suspended', - 'failed_attempts', - 'password', - 'created_at', - 'updated_at', - 'token', - 'account_id', 'user_type', + 'rfid_tag', + 'school_id', + 'failed_attempts', + 'last_failed_at', 'semester', 'school_year', - 'rfid_tag', - 'last_failed_at', + 'status', + 'is_suspended', + 'is_verified', + 'token', + 'updated_at', + 'created_at', ]; protected $hidden = [ @@ -71,9 +73,28 @@ class User extends Authenticatable implements JWTSubject return $this->getKey(); } - public function getJWTCustomClaims() + public function getJWTCustomClaims(): array { - return []; + return [ + 'name' => trim(($this->firstname ?? '').' '.($this->lastname ?? '')), + 'roles' => $this->roleNamesLikeCodeIgniter(), + ]; + } + + /** + * Role names joined from `user_roles` + `roles`, matching CodeIgniter `AuthController::apiLogin` (no roles.is_active filter). + * + * @return list + */ + public function roleNamesLikeCodeIgniter(): array + { + return DB::table('user_roles') + ->join('roles', 'roles.id', '=', 'user_roles.role_id') + ->where('user_roles.user_id', $this->id) + ->pluck('roles.name') + ->unique() + ->values() + ->all(); } /* ============================================================ diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php index 6e83b630..73ccd7a2 100644 --- a/app/Models/UserNotification.php +++ b/app/Models/UserNotification.php @@ -21,8 +21,6 @@ class UserNotification extends BaseModel 'is_read', 'delivered', 'delivered_at', - 'school_year', - 'semester', ]; protected $casts = [ diff --git a/app/Models/UserRole.php b/app/Models/UserRole.php index 179069e1..85497276 100644 --- a/app/Models/UserRole.php +++ b/app/Models/UserRole.php @@ -10,6 +10,8 @@ use Illuminate\Support\Facades\DB; class UserRole extends BaseModel { + public $timestamps = true; + use SoftDeletes; protected $table = 'user_roles'; @@ -17,10 +19,12 @@ class UserRole extends BaseModel protected $fillable = [ 'user_id', 'role_id', + 'semester', + 'school_year', 'created_at', 'updated_at', - 'deleted_at', 'updated_by', + 'deleted_at', ]; protected $casts = [ diff --git a/app/Models/WhatsappGroupLink.php b/app/Models/WhatsappGroupLink.php index 837bc42c..4df82633 100644 --- a/app/Models/WhatsappGroupLink.php +++ b/app/Models/WhatsappGroupLink.php @@ -16,10 +16,7 @@ class WhatsappGroupLink extends BaseModel 'semester', 'invite_link', 'active', - 'created_at', - 'updated_at', ]; - public $timestamps = true; // requires created_at/updated_at protected $casts = [ diff --git a/app/Models/WhatsappGroupMembership.php b/app/Models/WhatsappGroupMembership.php index 7acf7855..3b12d17a 100644 --- a/app/Models/WhatsappGroupMembership.php +++ b/app/Models/WhatsappGroupMembership.php @@ -14,14 +14,13 @@ class WhatsappGroupMembership extends BaseModel 'school_year', 'semester', 'subject_type', + 'primary', + 'second', 'subject_id', 'is_member', 'verified_by', 'verified_at', - 'created_at', - 'updated_at', ]; - public $timestamps = true; // created_at, updated_at protected $casts = [ diff --git a/app/Policies/ClassProgressReportPolicy.php b/app/Policies/ClassProgressReportPolicy.php index 2ab3fb01..8189bfd3 100644 --- a/app/Policies/ClassProgressReportPolicy.php +++ b/app/Policies/ClassProgressReportPolicy.php @@ -3,6 +3,8 @@ namespace App\Policies; use App\Models\ClassProgressReport; +use App\Models\Configuration; +use App\Models\TeacherClass; use App\Models\User; class ClassProgressReportPolicy @@ -34,7 +36,23 @@ class ClassProgressReportPolicy private function owns(User $user, ClassProgressReport $report): bool { - return (int) $report->teacher_id === (int) $user->id; + if ((int) $report->teacher_id === (int) $user->id) { + return true; + } + + $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + if ($schoolYear === '') { + return false; + } + + $semester = (string) (Configuration::getConfig('semester') ?? ''); + foreach (TeacherClass::assignedForSectionTerm((int) $report->class_section_id, $semester, $schoolYear) as $row) { + if ((int) ($row['teacher_id'] ?? 0) === (int) $user->id) { + return true; + } + } + + return false; } private function isAdmin(User $user): bool diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php old mode 100755 new mode 100644 index 2f646ba0..5fbd23f8 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,12 +35,15 @@ use App\Policies\ClassProgressReportPolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Routing\Redirector; +use App\Services\ApplicationUrlService; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { public function register(): void { + $this->app->singleton(ApplicationUrlService::class); + $this->app->singleton(AttendancePolicyService::class); $this->app->singleton(SemesterRangeService::class); $this->app->singleton(AttendanceRecordSyncService::class); diff --git a/app/Services/Admin/CompetitionWinners/CompetitionWinnersAdminService.php b/app/Services/Admin/CompetitionWinners/CompetitionWinnersAdminService.php new file mode 100644 index 00000000..d02cf3d7 --- /dev/null +++ b/app/Services/Admin/CompetitionWinners/CompetitionWinnersAdminService.php @@ -0,0 +1,742 @@ +classStudentTable = 'class_student'; + } elseif (Schema::hasTable('student_class')) { + $this->classStudentTable = 'student_class'; + } + + $this->hasClassWinnerTable = Schema::hasTable('competition_class_winners'); + $this->hasQuestionCount = $this->hasClassWinnerTable + && Schema::hasColumn('competition_class_winners', 'question_count'); + } + + /** @return array */ + public function getClassSectionMap(): array + { + $sections = ClassSection::query() + ->select('class_section_id', 'class_section_name') + ->orderBy('class_section_name', 'ASC') + ->get(); + + $map = []; + foreach ($sections as $section) { + $id = (int) ($section->class_section_id ?? 0); + if ($id > 0) { + $map[$id] = (string) ($section->class_section_name ?? $id); + } + } + + return $map; + } + + public function getClassSectionName(int $classSectionId): ?string + { + if ($classSectionId <= 0) { + return null; + } + + $row = ClassSection::query() + ->where('class_section_id', $classSectionId) + ->value('class_section_name'); + + return $row !== null ? (string) $row : null; + } + + /** @return array */ + public function getClassStudentCounts(?string $schoolYear): array + { + if ($this->classStudentTable === '') { + return []; + } + + $q = DB::table($this->classStudentTable) + ->select('class_section_id', DB::raw('COUNT(*) as total')) + ->whereNotNull('class_section_id') + ->groupBy('class_section_id'); + + if ($schoolYear && Schema::hasColumn($this->classStudentTable, 'school_year')) { + $q->where('school_year', $schoolYear); + } + + $map = []; + foreach ($q->get() as $row) { + $classId = (int) ($row->class_section_id ?? 0); + if ($classId > 0) { + $map[$classId] = (int) ($row->total ?? 0); + } + } + + return $map; + } + + /** + * @return array{overrides: array, questions: array, prizes: array>} + */ + public function getClassSettings(int $competitionId): array + { + if (! $this->hasClassWinnerTable) { + return ['overrides' => [], 'questions' => [], 'prizes' => []]; + } + + $rows = CompetitionClassWinner::query() + ->where('competition_id', $competitionId) + ->get(); + + $overrides = []; + $questions = []; + $prizes = []; + + foreach ($rows as $row) { + $classId = (int) ($row->class_section_id ?? 0); + if ($classId <= 0) { + continue; + } + if ($row->winners_override !== null) { + $overrides[$classId] = (int) $row->winners_override; + } + if ($this->hasQuestionCount && $row->question_count !== null) { + $questions[$classId] = (int) $row->question_count; + } + $prizes[$classId] = [ + 1 => CompetitionWinnersDomain::castPrize($row->prize_1 ?? null), + 2 => CompetitionWinnersDomain::castPrize($row->prize_2 ?? null), + 3 => CompetitionWinnersDomain::castPrize($row->prize_3 ?? null), + 4 => CompetitionWinnersDomain::castPrize($row->prize_4 ?? null), + 5 => CompetitionWinnersDomain::castPrize($row->prize_5 ?? null), + 6 => CompetitionWinnersDomain::castPrize($row->prize_6 ?? null), + ]; + } + + return ['overrides' => $overrides, 'questions' => $questions, 'prizes' => $prizes]; + } + + /** + * @param array $overrides + * @param array $questionCounts + * @param array $prizes + */ + public function saveClassSettings( + int $competitionId, + array $overrides, + array $questionCounts, + array $prizes, + ?int $limitClassId = null + ): void { + if (! $this->hasClassWinnerTable) { + return; + } + + $classIds = array_unique(array_merge(array_keys($overrides), array_keys($questionCounts), array_keys($prizes))); + + foreach ($classIds as $classId) { + $classId = (int) $classId; + if ($classId <= 0) { + continue; + } + if ($limitClassId !== null && $classId !== $limitClassId) { + continue; + } + + $rawOverride = trim((string) ($overrides[$classId] ?? '')); + $overrideValue = $rawOverride === '' ? null : (int) $rawOverride; + + $rawQuestions = trim((string) ($questionCounts[$classId] ?? '')); + $questionValue = $rawQuestions === '' ? null : (int) $rawQuestions; + + $existing = CompetitionClassWinner::query() + ->where('competition_id', $competitionId) + ->where('class_section_id', $classId) + ->first(); + + $prizeValues = CompetitionWinnersDomain::normalizePrizeInputs((array) ($prizes[$classId] ?? [])); + $hasPrize = array_filter($prizeValues, static fn ($v) => $v !== null); + + if ($overrideValue === null && $questionValue === null && empty($hasPrize)) { + if ($existing) { + $existing->delete(); + } + continue; + } + + $payload = [ + 'competition_id' => $competitionId, + 'class_section_id' => $classId, + 'winners_override' => $overrideValue, + 'prize_1' => $prizeValues[1], + 'prize_2' => $prizeValues[2], + 'prize_3' => $prizeValues[3], + 'prize_4' => $prizeValues[4], + 'prize_5' => $prizeValues[5], + 'prize_6' => $prizeValues[6], + ]; + + if ($this->hasQuestionCount) { + $payload['question_count'] = $questionValue; + } + + if ($existing) { + $existing->fill($payload); + $existing->save(); + } else { + CompetitionClassWinner::query()->create($payload); + } + } + } + + /** + * @param array $competition + * @return list> + */ + public function getStudentsForCompetition(array $competition, int $classSectionId): array + { + if ($classSectionId <= 0 || $this->classStudentTable === '') { + return []; + } + + $q = DB::table('students as s') + ->select('s.id', 's.school_id', 's.firstname', 's.lastname') + ->join($this->classStudentTable.' as cs', 'cs.student_id', '=', 's.id') + ->where('cs.class_section_id', $classSectionId); + + if (Schema::hasColumn($this->classStudentTable, 'school_year') && ! empty($competition['school_year'])) { + $q->where('cs.school_year', $competition['school_year']); + } + + return $q->distinct()->orderBy('s.lastname')->orderBy('s.firstname')->get()->map(fn ($r) => (array) $r)->all(); + } + + /** @return array */ + public function getQuestionCountsForCompetition(int $competitionId): array + { + if (! $this->hasQuestionCount || ! $this->hasClassWinnerTable) { + return []; + } + + $rows = CompetitionClassWinner::query() + ->where('competition_id', $competitionId) + ->whereNotNull('question_count') + ->get(['class_section_id', 'question_count']); + + $out = []; + foreach ($rows as $row) { + $classId = (int) ($row->class_section_id ?? 0); + if ($classId > 0 && $row->question_count !== null) { + $out[$classId] = (int) $row->question_count; + } + } + + return $out; + } + + /** @return array{competitions: \Illuminate\Support\Collection, sectionMap: array} */ + public function indexData(): array + { + $competitions = Competition::query()->orderByDesc('id')->get(); + $sectionMap = $this->getClassSectionMap(); + + return ['competitions' => $competitions, 'sectionMap' => $sectionMap]; + } + + /** @return array */ + public function createFormData(): array + { + $schoolYear = Configuration::getConfig('school_year'); + $classCounts = $this->getClassStudentCounts($schoolYear); + $classSections = ClassSection::query()->orderBy('class_section_name')->get()->map(fn ($r) => $r->toArray())->all(); + $classSections = CompetitionWinnersDomain::filterSectionsWithStudents($classSections, $classCounts); + $classRows = CompetitionWinnersDomain::buildClassRows($classSections, $classCounts, [], [], []); + + return [ + 'mode' => 'create', + 'competition' => null, + 'classSections' => $classSections, + 'classRows' => $classRows, + 'defaultSemester' => Configuration::getConfig('semester'), + 'defaultSchoolYear' => $schoolYear, + ]; + } + + /** @return array|null */ + public function settingsFormData(int $id): ?array + { + $competition = Competition::query()->find($id); + if (! $competition) { + return null; + } + + $row = $competition->toArray(); + $schoolYear = $row['school_year'] ?? Configuration::getConfig('school_year'); + $classCounts = $this->getClassStudentCounts($schoolYear); + $classSections = ClassSection::query()->orderBy('class_section_name')->get()->map(fn ($r) => $r->toArray())->all(); + $classSections = CompetitionWinnersDomain::filterSectionsWithStudents($classSections, $classCounts); + $settings = $this->getClassSettings((int) $id); + $limitClassId = (int) ($row['class_section_id'] ?? 0); + $classRows = CompetitionWinnersDomain::buildClassRows( + $classSections, + $classCounts, + $settings['overrides'], + $settings['questions'], + $settings['prizes'], + $limitClassId + ); + + return [ + 'mode' => 'edit', + 'competition' => $row, + 'classSections' => $classSections, + 'classRows' => $classRows, + 'defaultSemester' => Configuration::getConfig('semester'), + 'defaultSchoolYear' => $schoolYear, + ]; + } + + /** + * @param array $validated + */ + public function storeCompetition(array $validated, array $overrides, array $questionCounts, array $prizes, ?int $actorId): int + { + $classSectionId = $validated['class_section_id'] ?? null; + $classSectionId = $classSectionId !== '' && $classSectionId !== null ? (int) $classSectionId : null; + + $competition = Competition::query()->create([ + 'title' => $validated['title'], + 'semester' => $validated['semester'] ?? null, + 'school_year' => $validated['school_year'] ?? null, + 'class_section_id' => $classSectionId, + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + 'created_by' => $actorId, + ]); + + $this->saveClassSettings((int) $competition->id, $overrides, $questionCounts, $prizes, $classSectionId); + + return (int) $competition->id; + } + + /** + * @param array $validated + */ + public function updateCompetition(int $id, array $validated, array $overrides, array $questionCounts, array $prizes): bool + { + $competition = Competition::query()->find($id); + if (! $competition) { + return false; + } + + $classSectionId = $validated['class_section_id'] ?? null; + $classSectionId = $classSectionId !== '' && $classSectionId !== null ? (int) $classSectionId : null; + + $competition->update([ + 'title' => $validated['title'], + 'semester' => $validated['semester'] ?? null, + 'school_year' => $validated['school_year'] ?? null, + 'class_section_id' => $classSectionId, + 'start_date' => $validated['start_date'] ?? null, + 'end_date' => $validated['end_date'] ?? null, + ]); + + $this->saveClassSettings($id, $overrides, $questionCounts, $prizes, $classSectionId); + + return true; + } + + /** @return array|null */ + public function editScoresData(int $id, ?int $queryClassSectionId): ?array + { + $competition = Competition::query()->find($id); + if (! $competition) { + return null; + } + + $c = $competition->toArray(); + $lockedClassSectionId = (int) ($c['class_section_id'] ?? 0); + $selectedClassSectionId = (int) ($queryClassSectionId ?? 0); + $activeClassSectionId = $lockedClassSectionId > 0 ? $lockedClassSectionId : $selectedClassSectionId; + + $students = []; + $scoreMap = []; + + if ($activeClassSectionId > 0) { + $students = $this->getStudentsForCompetition($c, $activeClassSectionId); + $existingScores = CompetitionScore::query() + ->where('competition_id', $id) + ->where('class_section_id', $activeClassSectionId) + ->get(); + + foreach ($existingScores as $row) { + $scoreMap[(int) $row->student_id] = $row->score; + } + } + + $schoolYear = $c['school_year'] ?? null; + $classCounts = $this->getClassStudentCounts($schoolYear); + $classSections = ClassSection::query()->orderBy('class_section_name')->get()->map(fn ($r) => $r->toArray())->all(); + $classSections = CompetitionWinnersDomain::filterSectionsWithStudents($classSections, $classCounts); + $settings = $this->getClassSettings((int) $id); + $classStudentCount = $classCounts[$activeClassSectionId] ?? 0; + $classOverride = $settings['overrides'][$activeClassSectionId] ?? null; + $classAuto = CompetitionWinnersDomain::winnersForCount($classStudentCount); + $classFinal = $classOverride !== null ? $classOverride : $classAuto; + $classQuestionCount = $settings['questions'][$activeClassSectionId] ?? null; + + return [ + 'competition' => $c, + 'students' => $students, + 'scoreMap' => $scoreMap, + 'classSections' => $classSections, + 'activeClassSectionId' => $activeClassSectionId, + 'classSectionName' => $this->getClassSectionName($activeClassSectionId), + 'classSelectionLocked' => $lockedClassSectionId > 0, + 'classStudentCount' => $classStudentCount, + 'classAutoWinners' => $classAuto, + 'classOverrideWinners' => $classOverride, + 'classFinalWinners' => $classFinal, + 'classQuestionCount' => $classQuestionCount, + 'classPrizeMap' => $settings['prizes'][$activeClassSectionId] ?? [], + ]; + } + + /** @param array $scores */ + public function saveScores(int $competitionId, array $scores, ?int $postClassSectionId, array $competitionRow): bool + { + $classSectionId = (int) ($competitionRow['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + $classSectionId = (int) ($postClassSectionId ?? 0); + } + if ($classSectionId <= 0) { + return false; + } + + foreach ($scores as $studentId => $scoreValue) { + $scoreValue = trim((string) $scoreValue); + if ($scoreValue === '') { + continue; + } + + $scoreFloat = (float) $scoreValue; + + $existing = CompetitionScore::query() + ->where('competition_id', $competitionId) + ->where('student_id', (int) $studentId) + ->where('class_section_id', $classSectionId) + ->first(); + + if ($existing) { + $existing->score = $scoreFloat; + $existing->class_section_id = $classSectionId; + $existing->save(); + } else { + CompetitionScore::query()->create([ + 'competition_id' => $competitionId, + 'student_id' => (int) $studentId, + 'class_section_id' => $classSectionId, + 'score' => $scoreFloat, + ]); + } + } + + return true; + } + + /** @return array|null */ + public function previewData(int $id): ?array + { + $competition = Competition::query()->find($id); + if (! $competition) { + return null; + } + + $c = $competition->toArray(); + + $rows = DB::table('competition_scores as cs') + ->leftJoin('students as s', 's.id', '=', 'cs.student_id') + ->select('cs.*', 's.firstname', 's.lastname') + ->where('cs.competition_id', $id) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + $rankedByClass = CompetitionWinnersDomain::groupScoresByClass($rows); + $scoreCounts = CompetitionWinnersDomain::getScoreCountsByClass($rankedByClass); + + $classCounts = $this->getClassStudentCounts($c['school_year'] ?? null); + $settings = $this->getClassSettings((int) $id); + $winnersCountByClass = CompetitionWinnersDomain::getWinnersCountByClass( + $c, + $classCounts, + $scoreCounts, + $settings['overrides'] + ); + + $topByClass = CompetitionWinnersDomain::getTopWinnersByClass( + $rankedByClass, + $winnersCountByClass, + $settings['prizes'], + $settings['questions'] + ); + + return [ + 'competition' => $c, + 'classMap' => $this->getClassSectionMap(), + 'classCounts' => $classCounts, + 'overrideMap' => $settings['overrides'], + 'questionCounts' => $settings['questions'], + 'prizeMap' => $settings['prizes'], + 'winnersCountByClass' => $winnersCountByClass, + 'rankedByClass' => $rankedByClass, + 'topByClass' => $topByClass, + ]; + } + + /** @return list> */ + public function winnersListingRows(int $competitionId): array + { + return DB::table('competition_winners as cw') + ->selectRaw('cw.*, s.firstname, s.lastname, s.school_id, s.photo_consent, cs.class_section_name') + ->leftJoin('students as s', 's.id', '=', 'cw.student_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'cw.class_section_id') + ->where('cw.competition_id', $competitionId) + ->orderBy('cw.class_section_id') + ->orderBy('cw.rank') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + } + + public function publishWinners(int $id): bool + { + $competition = Competition::query()->find($id); + if (! $competition) { + return false; + } + + $c = $competition->toArray(); + + $rows = CompetitionScore::query()->where('competition_id', $id)->get()->map(fn ($r) => $r->toArray())->all(); + + $rankedByClass = CompetitionWinnersDomain::groupScoresByClass($rows); + $scoreCounts = CompetitionWinnersDomain::getScoreCountsByClass($rankedByClass); + $classCounts = $this->getClassStudentCounts($c['school_year'] ?? null); + $settings = $this->getClassSettings((int) $id); + $winnersCountByClass = CompetitionWinnersDomain::getWinnersCountByClass( + $c, + $classCounts, + $scoreCounts, + $settings['overrides'] + ); + + CompetitionWinner::query()->where('competition_id', $id)->delete(); + + foreach ($rankedByClass as $classSectionId => $rowsForClass) { + $limit = (int) ($winnersCountByClass[$classSectionId] ?? 0); + if ($limit <= 0) { + continue; + } + + $topWithPrizes = CompetitionWinnersDomain::assignPrizesByScore( + $rowsForClass, + $settings['prizes'][$classSectionId] ?? [], + $limit, + $settings['questions'][$classSectionId] ?? null + ); + + foreach ($topWithPrizes as $row) { + CompetitionWinner::query()->create([ + 'competition_id' => (int) $id, + 'student_id' => (int) $row['student_id'], + 'class_section_id' => (int) $classSectionId, + 'rank' => (int) $row['rank'], + 'score' => (float) $row['score'], + 'prize_amount' => (float) $row['prize_amount'], + 'created_at' => now()->toDateTimeString(), + ]); + } + } + + $competition->update([ + 'is_published' => 1, + 'published_at' => now()->toDateTimeString(), + ]); + + return true; + } + + public function lockCompetition(int $id, ?int $userId): bool + { + $competition = Competition::query()->find($id); + if (! $competition) { + return false; + } + + if ($competition->is_locked) { + return true; + } + + $competition->update([ + 'is_locked' => 1, + 'locked_at' => now()->toDateTimeString(), + 'locked_by' => $userId, + ]); + + return true; + } + + public function unlockCompetition(int $id): bool + { + $competition = Competition::query()->find($id); + if (! $competition) { + return false; + } + + if (! $competition->is_locked) { + return true; + } + + $competition->update([ + 'is_locked' => 0, + 'locked_at' => null, + 'locked_by' => null, + ]); + + return true; + } + + /** + * Export competition scores into quiz rows (SQL preserved from CodeIgniter). + * + * @return array{ok: bool, message: string, quizIndexes?: array} + */ + public function exportCompetitionToQuiz(int $competitionId, int $classSectionId, ?int $schoolId, ?int $userId, ?string $semester, ?string $schoolYear): array + { + if ($competitionId <= 0) { + return ['ok' => false, 'message' => 'Competition not found.']; + } + + $competition = Competition::query()->find($competitionId); + if (! $competition) { + return ['ok' => false, 'message' => 'Competition not found.']; + } + + $lockedClassSectionId = (int) ($competition->class_section_id ?? 0); + if ($lockedClassSectionId > 0) { + $classSectionId = $lockedClassSectionId; + } + + if ($classSectionId > 0 && $lockedClassSectionId > 0 && $lockedClassSectionId !== $classSectionId) { + return ['ok' => false, 'message' => 'This competition is locked to a different class section.']; + } + + $semester = $semester ?: Configuration::getConfig('semester'); + $schoolYear = $schoolYear ?: Configuration::getConfig('school_year'); + + $classSectionIds = []; + if ($classSectionId > 0) { + $classSectionIds = [$classSectionId]; + } else { + $rows = DB::table('competition_scores') + ->select('class_section_id') + ->distinct() + ->where('competition_id', $competitionId) + ->where('class_section_id', '>', 0) + ->get(); + + foreach ($rows as $row) { + $cid = (int) ($row->class_section_id ?? 0); + if ($cid > 0) { + $classSectionIds[] = $cid; + } + } + } + + if ($classSectionIds === []) { + return ['ok' => false, 'message' => 'No class sections with scores found for this competition.']; + } + + $quizIndexes = []; + + DB::transaction(function () use ($competitionId, $classSectionIds, $schoolId, $userId, $semester, $schoolYear, &$quizIndexes): void { + foreach ($classSectionIds as $cid) { + $cid = (int) $cid; + if ($cid <= 0) { + continue; + } + + $row = DB::selectOne( + 'SELECT COALESCE(MAX(quiz_index), 0) + 1 AS next_index + FROM quiz + WHERE class_section_id = ? + AND semester = ? + AND school_year = ?', + [$cid, $semester, $schoolYear] + ); + + $quizIndex = (int) ($row->next_index ?? 1); + + $sql = <<<'SQL' +INSERT INTO quiz +(student_id, school_id, class_section_id, updated_by, quiz_index, score, comment, semester, school_year, created_at, updated_at) +SELECT + cs.student_id, + ?, + cs.class_section_id, + ?, + ?, + ROUND(LEAST(100, GREATEST(0, (cs.score / NULLIF(ccw.question_count, 0)) * 100)), 2), + CONCAT('Competition ', cs.competition_id, ' normalized from ', cs.score, '/', ccw.question_count), + ?, + ?, + NOW(), NOW() +FROM competition_scores cs +JOIN competition_class_winners ccw + ON ccw.competition_id = cs.competition_id + AND ccw.class_section_id = cs.class_section_id +WHERE cs.competition_id = ? + AND cs.class_section_id = ? +SQL; + + DB::statement($sql, [ + $schoolId, + $userId, + $quizIndex, + $semester, + $schoolYear, + $competitionId, + $cid, + ]); + + $quizIndexes[$cid] = $quizIndex; + } + }); + + if ($quizIndexes === []) { + return ['ok' => false, 'message' => 'No scores were exported.']; + } + + return ['ok' => true, 'message' => 'Exported.', 'quizIndexes' => $quizIndexes]; + } +} diff --git a/app/Services/Admin/CompetitionWinners/CompetitionWinnersDomain.php b/app/Services/Admin/CompetitionWinners/CompetitionWinnersDomain.php new file mode 100644 index 00000000..327112b7 --- /dev/null +++ b/app/Services/Admin/CompetitionWinners/CompetitionWinnersDomain.php @@ -0,0 +1,345 @@ + */ + private const WINNER_TIERS = [ + ['min' => 1, 'max' => 12, 'winners' => 2], + ['min' => 13, 'max' => 16, 'winners' => 3], + ['min' => 17, 'max' => 22, 'winners' => 4], + ['min' => 23, 'max' => 27, 'winners' => 5], + ['min' => 28, 'max' => null, 'winners' => 6], + ]; + + public static function winnersForCount(int $count): int + { + if ($count <= 0) { + return 0; + } + + foreach (self::WINNER_TIERS as $tier) { + $min = (int) $tier['min']; + $max = $tier['max']; + if ($count < $min) { + continue; + } + if ($max === null || $count <= (int) $max) { + return (int) $tier['winners']; + } + } + + return 0; + } + + /** + * @param array> $classSections + * @param array $classCounts + * @param array $overrides + * @param array $questionCounts + * @param array> $prizeMap + * @return list> + */ + public static function buildClassRows( + array $classSections, + array $classCounts, + array $overrides, + array $questionCounts, + array $prizeMap, + int $limitClassId = 0 + ): array { + $rows = []; + foreach ($classSections as $section) { + $classId = (int) ($section['class_section_id'] ?? 0); + if ($classId <= 0) { + continue; + } + if ($limitClassId > 0 && $classId !== $limitClassId) { + continue; + } + + $count = $classCounts[$classId] ?? 0; + if ($count <= 0) { + continue; + } + $auto = self::winnersForCount($count); + $override = isset($overrides[$classId]) ? (int) $overrides[$classId] : null; + $questionCount = isset($questionCounts[$classId]) ? (int) $questionCounts[$classId] : null; + $prizes = $prizeMap[$classId] ?? []; + $rows[] = [ + 'class_section_id' => $classId, + 'class_section_name' => $section['class_section_name'] ?? (string) $classId, + 'student_count' => $count, + 'auto_winners' => $auto, + 'override_winners' => $override, + 'final_winners' => $override !== null ? $override : $auto, + 'question_count' => $questionCount, + 'prize_1' => $prizes[1] ?? null, + 'prize_2' => $prizes[2] ?? null, + 'prize_3' => $prizes[3] ?? null, + 'prize_4' => $prizes[4] ?? null, + 'prize_5' => $prizes[5] ?? null, + 'prize_6' => $prizes[6] ?? null, + ]; + } + + return $rows; + } + + /** + * @param array> $classSections + * @param array $classCounts + * @return array> + */ + public static function filterSectionsWithStudents(array $classSections, array $classCounts): array + { + return array_values(array_filter($classSections, static function ($section) use ($classCounts) { + $classId = (int) ($section['class_section_id'] ?? 0); + if ($classId <= 0) { + return false; + } + + return ($classCounts[$classId] ?? 0) > 0; + })); + } + + /** + * @param array $competition Row including optional class_section_id + * @param array $classCounts + * @param array $scoreCounts + * @param array $overrides + * @return array + */ + public static function getWinnersCountByClass( + array $competition, + array $classCounts, + array $scoreCounts, + array $overrides + ): array { + $classIds = array_unique(array_merge( + array_keys($classCounts), + array_keys($scoreCounts), + array_keys($overrides) + )); + + $limitClassId = (int) ($competition['class_section_id'] ?? 0); + $map = []; + foreach ($classIds as $classId) { + $classId = (int) $classId; + if ($classId <= 0) { + continue; + } + if ($limitClassId > 0 && $classId !== $limitClassId) { + continue; + } + + $override = $overrides[$classId] ?? null; + if ($override !== null) { + $map[$classId] = (int) $override; + continue; + } + + $baseCount = $classCounts[$classId] ?? ($scoreCounts[$classId] ?? 0); + if ($baseCount <= 0) { + $map[$classId] = 0; + continue; + } + + $maxWinners = self::winnersForCount((int) $baseCount); + $minWinners = 3; + $map[$classId] = $maxWinners >= $minWinners ? $maxWinners : $minWinners; + } + + return $map; + } + + /** + * @param array> $rows + * @return array>> + */ + public static function groupScoresByClass(array $rows): array + { + $grouped = []; + + foreach ($rows as $row) { + $classSectionId = (int) ($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $name = trim(($row['firstname'] ?? '').' '.($row['lastname'] ?? '')); + $score = (float) ($row['score'] ?? 0); + + $grouped[$classSectionId][] = [ + 'student_id' => $row['student_id'], + 'name' => $name !== '' ? $name : ('Student #'.$row['student_id']), + 'score' => $score, + ]; + } + + foreach ($grouped as &$rowsForClass) { + usort($rowsForClass, static function ($a, $b) { + return $b['score'] <=> $a['score']; + }); + } + unset($rowsForClass); + + return $grouped; + } + + /** + * @param array>> $rankedByClass + * @return array + */ + public static function getScoreCountsByClass(array $rankedByClass): array + { + $counts = []; + foreach ($rankedByClass as $classId => $rows) { + $counts[(int) $classId] = count($rows); + } + + return $counts; + } + + /** + * @param array>> $rankedByClass + * @param array $winnersCountByClass + * @param array> $prizeMap + * @param array $questionCounts + * @return array>> + */ + public static function getTopWinnersByClass( + array $rankedByClass, + array $winnersCountByClass, + array $prizeMap, + array $questionCounts + ): array { + $out = []; + + foreach ($rankedByClass as $classSectionId => $rows) { + $limit = (int) ($winnersCountByClass[$classSectionId] ?? 0); + if ($limit <= 0) { + continue; + } + + $out[$classSectionId] = self::assignPrizesByScore( + $rows, + $prizeMap[$classSectionId] ?? [], + $limit, + $questionCounts[$classSectionId] ?? null + ); + } + + return $out; + } + + /** + * @param array $prizes + * @return array + */ + public static function normalizePrizeInputs(array $prizes): array + { + $out = [1 => null, 2 => null, 3 => null, 4 => null, 5 => null, 6 => null]; + foreach ($out as $rank => $_value) { + $raw = trim((string) ($prizes[$rank] ?? '')); + if ($raw === '') { + $out[$rank] = null; + } else { + $out[$rank] = (float) $raw; + } + } + + return $out; + } + + public static function castPrize(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + + return (float) $value; + } + + /** + * @param array $classPrizes + */ + public static function getPrizeForRank(array $classPrizes, int $rank): float + { + $val = $classPrizes[$rank] ?? null; + + return $val !== null ? (float) $val : 0.0; + } + + /** + * @param list> $rows + * @param array $classPrizes + * @return list> + */ + public static function assignPrizesByScore(array $rows, array $classPrizes, int $limit, ?int $questionCount = null): array + { + if ($limit <= 0) { + return []; + } + + usort($rows, static function ($a, $b) { + return ($b['score'] ?? 0) <=> ($a['score'] ?? 0); + }); + + $scoreGroups = []; + $scoreOrder = []; + foreach ($rows as $row) { + $score = (float) ($row['score'] ?? 0); + $scoreKey = number_format($score, 5, '.', ''); + if (! array_key_exists($scoreKey, $scoreGroups)) { + $scoreGroups[$scoreKey] = []; + $scoreOrder[] = $scoreKey; + } + $scoreGroups[$scoreKey][] = $row; + } + + $top = []; + $rank = 1; + foreach ($scoreOrder as $scoreKey) { + if (count($top) >= $limit) { + break; + } + foreach ($scoreGroups[$scoreKey] as $row) { + $row['rank'] = $rank; + $top[] = $row; + } + $rank++; + } + + $sharedPrize = null; + if ($questionCount !== null && $questionCount > 0 && ! empty($top)) { + $allMax = true; + foreach ($top as $row) { + $score = (float) ($row['score'] ?? 0); + if ($score < $questionCount) { + $allMax = false; + break; + } + } + if ($allMax) { + $sharedPrize = self::getPrizeForRank($classPrizes, 1); + } + } + + $out = []; + foreach ($top as $row) { + if ($sharedPrize !== null) { + $prize = $sharedPrize; + } else { + $prize = self::getPrizeForRank($classPrizes, (int) ($row['rank'] ?? 1)); + } + + $row['prize_amount'] = $prize; + $out[] = $row; + } + + return $out; + } +} diff --git a/app/Services/Administrator/AdministratorAbsenceService.php b/app/Services/Administrator/AdministratorAbsenceService.php index c47ae826..b15c51a0 100644 --- a/app/Services/Administrator/AdministratorAbsenceService.php +++ b/app/Services/Administrator/AdministratorAbsenceService.php @@ -2,6 +2,7 @@ namespace App\Services\Administrator; +use App\Services\ApplicationUrlService; use App\Services\Staff\StaffTimeOffLinkService; use App\Models\StaffAttendance; use App\Models\User; @@ -18,6 +19,7 @@ class AdministratorAbsenceService protected StaffAttendance $staffAttendanceModel, protected SemesterRangeService $semesterRangeService, protected StaffTimeOffLinkService $staffTimeOffLinkService, + protected ApplicationUrlService $urls, ) { } @@ -209,7 +211,7 @@ class AdministratorAbsenceService 'origin' => 'administrator portal', ]); - $notifyUrl = url('/timeoff/notify/' . rawurlencode($token)); + $notifyUrl = $this->urls->timeoffNotifyUrl($token); $body .= '

Click Send confirmation email to ' . e($fullName) diff --git a/app/Services/Administrator/AdministratorEnrollmentEventService.php b/app/Services/Administrator/AdministratorEnrollmentEventService.php index 56c86f59..957d4029 100644 --- a/app/Services/Administrator/AdministratorEnrollmentEventService.php +++ b/app/Services/Administrator/AdministratorEnrollmentEventService.php @@ -3,10 +3,16 @@ namespace App\Services\Administrator; use App\Models\Invoice; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\Event; class AdministratorEnrollmentEventService { + public function __construct( + private ApplicationUrlService $urls, + ) { + } + public function dispatchGroupedEvents( array $groupsByParentStatus, array $parentInfo, @@ -57,7 +63,7 @@ class AdministratorEnrollmentEventService 'firstname' => $p['firstname'], 'lastname' => $p['lastname'], 'school_year' => $schoolYear, - 'portalLink' => url('/login'), + 'portalLink' => $this->urls->webLoginUrl(), ]; if ($status === 'payment pending' && $invoice) { diff --git a/app/Services/Administrator/TeacherSubmissionNotificationService.php b/app/Services/Administrator/TeacherSubmissionNotificationService.php index c738e87a..bbf6425a 100644 --- a/app/Services/Administrator/TeacherSubmissionNotificationService.php +++ b/app/Services/Administrator/TeacherSubmissionNotificationService.php @@ -3,6 +3,7 @@ namespace App\Services\Administrator; use App\Mail\TeacherSubmissionReminderMail; +use App\Services\ApplicationUrlService; use App\Models\ClassSection; use App\Models\TeacherSubmissionNotificationHistory; use App\Models\User; @@ -14,7 +15,8 @@ class TeacherSubmissionNotificationService { public function __construct( protected AdministratorSharedService $shared, - protected TeacherSubmissionSupportService $support + protected TeacherSubmissionSupportService $support, + protected ApplicationUrlService $urls, ) { } @@ -58,7 +60,7 @@ class TeacherSubmissionNotificationService $adminUser = User::find($adminId); $adminName = trim(($adminUser->firstname ?? '') . ' ' . ($adminUser->lastname ?? '')) ?: 'Administrator'; - $scoreUrl = url('/'); + $scoreUrl = $this->urls->docsHomeUrl(); $sentCount = 0; $failCount = 0; diff --git a/app/Services/ApplicationUrlService.php b/app/Services/ApplicationUrlService.php new file mode 100644 index 00000000..983c6dc1 --- /dev/null +++ b/app/Services/ApplicationUrlService.php @@ -0,0 +1,197 @@ + $plainToken]); + } + + public function inviteSetPasswordFormUrl(int $authorizedUserId, string $token): string + { + return route('api.invite.set-password-form', [ + 'authorizedUserId' => $authorizedUserId, + 'token' => $token, + ]); + } + + public function timeoffNotifyUrl(string $token): string + { + return route('api.timeoff.notify', ['token' => $token]); + } + + /** Served by {@see \App\Http\Controllers\Api\Reports\FilesController::receipt} (uploads/receipts). */ + public function forReceiptStorage(?string $filename): ?string + { + if (!$filename) { + return null; + } + $safe = basename(trim($filename)); + + return $safe !== '' ? route('api.v1.files.receipt', ['name' => $safe]) : null; + } + + /** Served by {@see \App\Http\Controllers\Api\Reports\FilesController::reimb} (uploads/reimbursements). */ + public function forReimbursementStorage(?string $filename): ?string + { + if (!$filename) { + return null; + } + $safe = basename(trim($filename)); + + return $safe !== '' ? route('api.v1.files.reimbursement', ['name' => $safe]) : null; + } + + public function forReimbursementAdminCheckFile(string $filename, string $mode = 'inline'): string + { + return route('api.v1.finance.reimbursements.admin-file', [ + 'name' => $filename, + 'mode' => $mode, + ]); + } + + public function paypalExecuteReturnUrl(): string + { + return route('api.v1.finance.paypal.execute'); + } + + public function paypalCancelUrl(): string + { + return route('api.v1.finance.paypal.cancel'); + } + + public function studentReportCardUrl(int $studentId): string + { + return route('api.v1.students.report-card', ['studentId' => $studentId]); + } + + public function forClassProgressAttachment(int|string $attachmentId): string + { + return route('api.v1.class-progress.attachment', ['attachment' => $attachmentId]); + } + + /** + * @return array{template_endpoint: string, list_endpoint: string, rest_prefix: string, list_data_endpoint: string} + */ + public function attendanceCommentTemplateBootstrapData(): array + { + return [ + 'template_endpoint' => route('api.v1.attendance-templates.legacy'), + 'list_endpoint' => route('api.v1.attendance-templates.legacy'), + 'rest_prefix' => route('api.v1.attendance-comment-templates.index'), + 'list_data_endpoint' => route('api.v1.attendance-comment-templates.list-data'), + ]; + } +} diff --git a/app/Services/Attendance/AttendanceConsequenceService.php b/app/Services/Attendance/AttendanceConsequenceService.php index a3882d40..6551d529 100644 --- a/app/Services/Attendance/AttendanceConsequenceService.php +++ b/app/Services/Attendance/AttendanceConsequenceService.php @@ -52,7 +52,7 @@ class AttendanceConsequenceService string $type, array $payload, string $defaultSubject, - string $viewName, + string $_viewName, string $logLevel ): array { $studentName = trim((string) ($payload['student']['firstname'] ?? '') . ' ' . (string) ($payload['student']['lastname'] ?? '')); @@ -64,7 +64,6 @@ class AttendanceConsequenceService $schoolYear = (string) ($payload['school_year'] ?? ''); $dateYmd = (string) ($payload['date'] ?? ''); $dateFmt = $dateYmd !== '' ? date('m-d-Y', strtotime($dateYmd)) : ''; - $sentAt = $payload['now'] ?? utc_now(); if ($parentEmail === '') { Log::warning("Attendance {$type}: missing parent email", [ @@ -75,25 +74,16 @@ class AttendanceConsequenceService $subject = (string) ($payload['subject'] ?? $this->buildSubject($defaultSubject, $studentName, $dateFmt)); - $emailData = [ - 'title' => $subject, - 'student_name' => $studentName, - 'parent_name' => $parentName, - 'teacher_name' => $teacherName, - 'class_section_name' => $className, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'date_fmt' => $dateFmt, - 'sent_at' => $sentAt, - ]; - $html = trim((string) ($payload['html'] ?? '')); if ($html === '') { - try { - $html = view($viewName, $emailData, ['saveData' => true]); - } catch (\Throwable $e) { - $html = '

' . e($subject) . '

'; - } + $period = trim($semester . ' ' . $schoolYear); + $html = '

' . e($subject) . '

' + . '

Student: ' . e($studentName) . '

' + . '

Parent: ' . e($parentName) . '

' + . '

Teacher: ' . e($teacherName) . '

' + . '

Class: ' . e($className) . '

' + . ($period !== '' ? '

Term: ' . e($period) . '

' : '') + . '

Date: ' . e($dateFmt) . '

'; } $ok = false; diff --git a/app/Services/Attendance/StaffAttendanceService.php b/app/Services/Attendance/StaffAttendanceService.php index 7da773e7..a1662857 100644 --- a/app/Services/Attendance/StaffAttendanceService.php +++ b/app/Services/Attendance/StaffAttendanceService.php @@ -2,11 +2,12 @@ namespace App\Services\Attendance; +use App\Models\AttendanceDay; use App\Models\Configuration; use App\Models\StaffAttendance; use App\Models\TeacherClass; -use App\Models\ClassSection; use Illuminate\Contracts\Auth\Authenticatable; +use App\Models\ClassSection; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -20,6 +21,163 @@ class StaffAttendanceService protected SemesterRangeService $semesterRangeService, ) {} + /** + * CodeIgniter {@see \App\Controllers\View\AttendanceController::index} — single date / section teacher grid JSON. + */ + public function teacherAttendanceDayIndex(?string $semester, ?string $schoolYear, string $dateYmd, int $sectionCode): array + { + $semester = $semester ?: (string) $this->configuration->getConfig('semester'); + $schoolYear = $schoolYear ?: (string) $this->configuration->getConfig('school_year'); + + $assignedBySection = TeacherClass::assignedBySectionForTerm($semester, $schoolYear); + $sectionCodes = array_keys($assignedBySection); + + $labels = []; + foreach ($sectionCodes as $code) { + $code = (int) $code; + if ($code <= 0) { + continue; + } + + $name = $this->classSection->query() + ->where('class_section_id', $code) + ->value('class_section_name'); + $name = trim((string) $name); + $labels[$code] = $name !== '' ? $name : ('Section #' . $code); + } + + $grid = []; + $locked = false; + + if ($sectionCode > 0) { + $assigned = TeacherClass::assignedForSectionTerm($sectionCode, $semester, $schoolYear); + $teacherIds = array_values(array_filter(array_map(static fn ($r) => (int) ($r['teacher_id'] ?? 0), $assigned))); + + $statusMap = []; + if ($teacherIds !== []) { + $rows = DB::table('staff_attendance as sa') + ->select(['sa.user_id', 'sa.status', 'sa.reason']) + ->where('sa.semester', $semester) + ->where('sa.school_year', $schoolYear) + ->whereDate('sa.date', $dateYmd) + ->whereIn('sa.user_id', $teacherIds) + ->get(); + + foreach ($rows as $r) { + $statusMap[(int) $r->user_id] = [ + 'status' => $r->status ?? null, + 'reason' => $r->reason ?? null, + ]; + } + } + + foreach ($assigned as $t) { + $tid = (int) ($t['teacher_id'] ?? 0); + $posRaw = strtolower((string) ($t['position'] ?? 'main')); + $pos = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main'; + $name = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? '')) ?: ('User#' . $tid); + + $cell = $statusMap[$tid] ?? ['status' => null, 'reason' => null]; + + $grid[] = [ + 'teacher_id' => $tid, + 'name' => $name, + 'position' => $pos, + 'status' => $cell['status'], + 'reason' => $cell['reason'], + ]; + } + + try { + $locked = AttendanceDay::isFinalized($sectionCode, $dateYmd, $semester, $schoolYear); + } catch (\Throwable) { + $locked = false; + } + } + + return [ + 'semester' => $semester, + 'school_year' => $schoolYear, + 'date' => $dateYmd, + 'class_section_id' => $sectionCode, + 'sections' => $labels, + 'grid' => $grid, + 'locked' => $locked, + ]; + } + + /** + * Bulk save for CI {@see \App\Controllers\View\AttendanceController::save} (method missing in CI; implemented here). + */ + public function saveTeacherAttendanceBulk(Authenticatable $user, array $payload): array + { + $sectionCode = (int) ($payload['class_section_id'] ?? 0); + $date = substr((string) ($payload['date'] ?? ''), 0, 10); + $semester = (string) ($payload['semester'] ?? ''); + $schoolYear = (string) ($payload['school_year'] ?? ''); + $teachers = (array) ($payload['teachers'] ?? []); + + if ($sectionCode <= 0 || $date === '' || $semester === '' || $schoolYear === '') { + throw new \RuntimeException('Missing required fields.'); + } + + $assigned = TeacherClass::assignedForSectionTerm($sectionCode, $semester, $schoolYear); + $assignedByTeacher = []; + foreach ($assigned as $row) { + $assignedByTeacher[(int) ($row['teacher_id'] ?? 0)] = $row; + } + + DB::transaction(function () use ($teachers, $assignedByTeacher, $date, $semester, $schoolYear) { + foreach ($teachers as $tidKey => $row) { + $tid = (int) (is_numeric((string) $tidKey) ? $tidKey : ($row['teacher_id'] ?? 0)); + if ($tid <= 0) { + continue; + } + + $status = strtolower(trim((string) ($row['status'] ?? ''))); + $reason = trim((string) ($row['reason'] ?? '')); + + $assignment = $assignedByTeacher[$tid] ?? null; + $roleLabel = $assignment + ? ((strtolower((string) ($assignment['position'] ?? '')) === 'ta') ? 'Teacher Assistant' : 'Teacher') + : 'Teacher'; + + if ($status === '') { + DB::table('staff_attendance') + ->where('user_id', $tid) + ->whereDate('date', $date) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->delete(); + + continue; + } + + if (! in_array($status, ['present', 'absent', 'late'], true)) { + continue; + } + + DB::table('staff_attendance')->updateOrInsert( + [ + 'user_id' => $tid, + 'date' => $date, + 'semester' => $semester, + 'school_year' => $schoolYear, + ], + [ + 'role_name' => $roleLabel, + 'status' => $status, + 'reason' => $reason !== '' ? $reason : null, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + }); + + return ['ok' => true, 'message' => 'Teacher attendance saved successfully.']; + } + public function monthData(?string $semester, ?string $schoolYear): array { $semester = $semester ?: (string)$this->configuration->getConfig('semester'); diff --git a/app/Services/AttendanceTracking/AttendanceEmailComposerService.php b/app/Services/AttendanceTracking/AttendanceEmailComposerService.php index 2588ac56..0ad29f50 100644 --- a/app/Services/AttendanceTracking/AttendanceEmailComposerService.php +++ b/app/Services/AttendanceTracking/AttendanceEmailComposerService.php @@ -3,6 +3,7 @@ namespace App\Services\AttendanceTracking; use App\Models\AttendanceEmailTemplate; +use App\Support\MailHtml; use Illuminate\Support\Str; class AttendanceEmailComposerService @@ -64,10 +65,7 @@ class AttendanceEmailComposerService public function renderWithEmailLayout(string $subject, string $bodyHtml): string { - return view('emails._wrap_layout', [ - 'title' => $subject, - 'body_html' => $bodyHtml, - ])->render(); + return MailHtml::document($subject, $bodyHtml); } public function pickVariant(string $code, ?string $globalVariantOverride = null): string diff --git a/app/Services/AttendanceTracking/AttendanceTrackingService.php b/app/Services/AttendanceTracking/AttendanceTrackingService.php index 4995372b..91396598 100644 --- a/app/Services/AttendanceTracking/AttendanceTrackingService.php +++ b/app/Services/AttendanceTracking/AttendanceTrackingService.php @@ -2,6 +2,169 @@ namespace App\Services\AttendanceTracking; +use Illuminate\Support\Facades\Validator; + +/** + * Facade used by {@see \App\Http\Controllers\Api\AttendanceTracking\AttendanceTrackingController}. + */ class AttendanceTrackingService { + public function __construct( + protected AttendancePendingViolationService $pendingViolationService, + protected AttendanceCaseQueryService $caseQueryService, + protected AttendanceNotificationWorkflowService $workflowService, + protected AttendanceCommunicationSupportService $communicationSupportService, + ) { + } + + protected function defaultSchoolYear(): string + { + return (string) (Configuration::getConfig('school_year') ?? ''); + } + + protected function defaultSemester(): string + { + return (string) (Configuration::getConfig('semester') ?? ''); + } + + public function getPendingViolations(?string $schoolYearParam, ?string $semesterParam): array + { + return $this->pendingViolationService->getPendingViolations( + $this->defaultSchoolYear(), + $schoolYearParam, + $semesterParam + ); + } + + public function getNotifiedViolations(?string $schoolYearParam, ?string $semesterParam): array + { + return $this->caseQueryService->getNotifiedViolations( + $schoolYearParam, + $semesterParam, + $this->defaultSchoolYear(), + $this->defaultSemester() + ); + } + + public function getStudentCase( + int $studentId, + ?string $codeParam, + ?string $incidentDate, + bool $includeNotified, + bool $pendingOnly, + ?string $schoolYearFilter, + ?string $semesterFilter, + ): array { + return $this->caseQueryService->getStudentCase( + $studentId, + $codeParam, + $incidentDate, + $includeNotified, + $pendingOnly, + $schoolYearFilter, + $semesterFilter, + $this->defaultSchoolYear(), + $this->defaultSemester() + ); + } + + public function record(array $data): array + { + return $this->workflowService->record($data); + } + + /** + * CodeIgniter POST attendance/send-auto-emails — runs auto_email actions on current pending violations. + */ + public function sendAutoEmails(mixed $variantInput): array + { + $variant = is_string($variantInput) ? $variantInput : null; + + $pending = $this->getPendingViolations(null, null); + $violations = $pending['students'] ?? []; + + return $this->workflowService->sendAutoEmails($violations, $variant); + } + + public function compose(int $studentId, string $code, string $variant, ?string $incidentDate): array + { + return $this->communicationSupportService->compose($studentId, $code, $variant, $incidentDate); + } + + public function parentsInfo(int $studentId): array + { + return $this->communicationSupportService->parentsInfo($studentId); + } + + public function sendEmailManual(array $data): array + { + $studentId = (int) ($data['student_id'] ?? 0); + $code = (string) ($data['code'] ?? ''); + $incidentRaw = $data['incident_date'] ?? null; + $incidentDate = $incidentRaw !== null && $incidentRaw !== '' + ? substr((string) $incidentRaw, 0, 10) + : null; + + $violationDates = $this->communicationSupportService->getViolationDatesForStudent( + $studentId, + $code, + $incidentDate + ); + + return $this->workflowService->sendEmailManual($data, $violationDates); + } + + public function saveNotificationNote(array $data): array + { + return $this->workflowService->saveNotificationNote($data); + } + + /** + * CodeIgniter route referenced {@see View\AttendanceTrackingController::notify_parent} was never implemented in CI; + * this matches the attendance notification modal (student_id, to, subject, message, subject_type, date). + */ + public function notifyParent(array $input): array + { + $validator = Validator::make($input, [ + 'student_id' => ['required', 'integer', 'min:1'], + 'to' => ['required', 'email'], + 'subject' => ['required', 'string'], + 'message' => ['required', 'string'], + 'subject_type' => ['nullable', 'string'], + 'date' => ['nullable', 'date'], + ]); + + if ($validator->fails()) { + return [ + 'success' => false, + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + 'status' => 422, + ]; + } + + $data = $validator->validated(); + $subjectType = strtolower((string) ($data['subject_type'] ?? 'absent')); + + $code = str_contains($subjectType, 'late') ? 'LATE_1' : 'ABS_1'; + + $ymd = null; + if (! empty($data['date'])) { + try { + $ymd = \Carbon\Carbon::parse((string) $data['date'])->format('Y-m-d'); + } catch (\Throwable) { + $ymd = null; + } + } + + return $this->sendEmailManual([ + 'student_id' => (int) $data['student_id'], + 'to' => (string) $data['to'], + 'subject' => (string) $data['subject'], + 'body_html' => (string) $data['message'], + 'code' => $code, + 'variant' => 'default', + 'incident_date' => $ymd, + ]); + } } diff --git a/app/Services/Auth/ApiLoginSecurityService.php b/app/Services/Auth/ApiLoginSecurityService.php new file mode 100644 index 00000000..a785ab0e --- /dev/null +++ b/app/Services/Auth/ApiLoginSecurityService.php @@ -0,0 +1,130 @@ +where('ip_address', $ip)->first(); + if (!$row || !$row->blocked_until) { + return false; + } + + return $row->blocked_until->isFuture(); + } + + public function logIpAttempt(string $ip): void + { + $now = utc_now(); + + $attempt = IpAttempt::query()->where('ip_address', $ip)->first(); + if ($attempt) { + $attempts = ((int) $attempt->attempts) + 1; + $blockedUntil = null; + if ($attempts >= 10) { + $blockedUntil = date('Y-m-d H:i:s', strtotime('+24 hours')); + } + + $attempt->update([ + 'attempts' => $attempts, + 'last_attempt_at' => $now, + 'blocked_until' => $blockedUntil, + ]); + + return; + } + + IpAttempt::query()->create([ + 'ip_address' => $ip, + 'attempts' => 1, + 'last_attempt_at' => $now, + ]); + } + + public function handleFailedLogin(User $user, string $email, string $ip): void + { + $user->refresh(); + + $failedAttempts = ((int) ($user->failed_attempts ?? 0)) + 1; + $data = [ + 'failed_attempts' => $failedAttempts, + 'last_failed_at' => utc_now(), + ]; + + if ($failedAttempts >= 3) { + $data['is_suspended'] = true; + // CI sends reset email via View\UserController::sendResetEmail — wire Laravel mail here when parity is needed. + Log::notice('api_login: account suspended after failed attempts', ['email' => $email, 'user_id' => $user->id]); + } + + $user->fill($data); + $user->save(); + + $this->logIpAttempt($ip); + } + + public function resetFailedAttempts(User $user): void + { + $user->failed_attempts = 0; + $user->last_failed_at = null; + $user->save(); + } + + public function logSuccessfulLogin(User $user, Request $request): void + { + LoginActivity::query()->create([ + 'user_id' => $user->id, + 'email' => $user->email, + 'login_time' => utc_now(), + 'ip_address' => $request->ip(), + 'user_agent' => (string) ($request->userAgent() ?? ''), + ]); + } + + /** + * CI returns roles as an object map {"RoleName": true}. + * + * @param list $roleNames + */ + public function rolesToObjectMap(array $roleNames): object + { + $rolesMap = []; + foreach ($roleNames as $r) { + $rolesMap[$r] = true; + } + + return (object) $rolesMap; + } + + /** + * Top-level JSON body aligned with the CodeIgniter 4 `AuthController::apiLogin` success payload. + * + * @return array{status: true, token: string, user: array{id: int, name: string, roles: object}} + */ + public function buildLoginResponse(User $user, string $jwtToken): array + { + $roleNames = $user->roleNamesLikeCodeIgniter(); + $fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? '')); + + return [ + 'status' => true, + 'token' => $jwtToken, + 'user' => [ + 'id' => (int) $user->id, + 'name' => $fullName, + 'roles' => $this->rolesToObjectMap($roleNames), + ], + ]; + } +} diff --git a/app/Services/Auth/AuthSessionService.php b/app/Services/Auth/AuthSessionService.php new file mode 100644 index 00000000..23b38dc1 --- /dev/null +++ b/app/Services/Auth/AuthSessionService.php @@ -0,0 +1,206 @@ +urls->docsHomeUrl(), PHP_URL_HOST); + $targetHost = (string) parse_url($redirectTo, PHP_URL_HOST); + $targetPath = (string) parse_url($redirectTo, PHP_URL_PATH); + $targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY); + + if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) { + return null; + } + + $redirectTo = $targetPath !== '' ? $targetPath : '/'; + if ($targetQuery !== '') { + $redirectTo .= '?'.$targetQuery; + } + } + + if (! str_starts_with($redirectTo, '/')) { + $redirectTo = '/'.ltrim($redirectTo, '/'); + } + + if (str_starts_with($redirectTo, '//')) { + return null; + } + + return $redirectTo; + } + + /** + * Highest-priority dashboard route matching any of the role names/slugs. + * + * @param list $roleNames + */ + public function dashboardRouteForRoles(array $roleNames): string + { + if ($roleNames === []) { + return self::FALLBACK_DASHBOARD; + } + + $rows = Role::findByNamesOrSlugs($roleNames); + if ($rows !== []) { + $route = $rows[0]->dashboard_route ?? null; + if (is_string($route) && $route !== '') { + return $route; + } + } + + return self::FALLBACK_DASHBOARD; + } + + /** + * Apply CI-style preference keys into session (style/menu colors). + */ + public function applyStylePreferencesToSession(int $userId): void + { + if ($userId <= 0) { + return; + } + + $prefs = Preferences::query()->where('user_id', $userId)->first(); + if (! $prefs) { + return; + } + + $styleColor = (string) ($prefs->style_color ?? ''); + if ($styleColor !== '') { + session()->put('style_color', $styleColor); + } + + $menuColor = (string) ($prefs->menu_color ?? ''); + if ($menuColor === 'custom') { + session()->put('menu_color', 'custom'); + session()->put('menu_custom_bg', (string) ($prefs->menu_custom_bg ?? '#0f172a')); + session()->put('menu_custom_text', (string) ($prefs->menu_custom_text ?? '#ffffff')); + session()->put('menu_custom_mode', (string) ($prefs->menu_custom_mode ?? 'dark')); + } elseif ($menuColor !== '') { + session()->put('menu_color', $menuColor); + } + } + + /** + * Populate Laravel web auth + CI-compatible session keys. + * + * @param list $roleNames + */ + public function writeCiAuthenticatedSession(User $user, array $roleNames): void + { + Auth::guard('web')->login($user); + + $schoolYear = Configuration::getConfig('school_year'); + $semester = Configuration::getConfig('semester'); + + session([ + 'user_id' => $user->id, + 'user_email' => $user->email, + 'user_name' => trim(($user->firstname ?? '').' '.($user->lastname ?? '')), + 'user_type' => $user->user_type ?? null, + 'is_logged_in' => true, + 'login_time' => time(), + 'last_activity' => time(), + 'roles' => $roleNames, + 'semester' => $semester, + 'school_year' => $schoolYear, + ]); + + $this->applyStylePreferencesToSession((int) $user->id); + } + + /** + * Close open login_activity row(s) without logout_time for this user (CI parity). + */ + public function logLogout(?int $userId, ?string $email): void + { + if (! $userId || ! $email) { + return; + } + + LoginActivity::query() + ->where('user_id', $userId) + ->whereNull('logout_time') + ->update([ + 'logout_time' => utc_now(), + 'email' => $email, + ]); + } + + /** + * Determine next navigation after credentials validated (single vs multi-role). + * + * @return array{kind: string, redirect_url?: string|null, roles?: array, reason?: string} + */ + public function resolvePostLoginDestination(User $user, ?string $sanitizedRedirect): array + { + $roleNames = $user->roleNamesLikeCodeIgniter(); + + if ($roleNames === []) { + Log::notice('session_login: user has no roles', ['user_id' => $user->id]); + + return [ + 'kind' => 'no_roles', + 'reason' => 'Role not assigned. Please contact support.', + ]; + } + + $this->writeCiAuthenticatedSession($user, $roleNames); + + if (count($roleNames) === 1) { + session()->put('role', $roleNames[0]); + if ($sanitizedRedirect !== null) { + return ['kind' => 'redirect', 'redirect_url' => $sanitizedRedirect]; + } + + return [ + 'kind' => 'redirect', + 'redirect_url' => $this->dashboardRouteForRoles([$roleNames[0]]), + ]; + } + + if ($sanitizedRedirect !== null) { + session()->put('post_login_redirect', $sanitizedRedirect); + } else { + session()->forget('post_login_redirect'); + } + + return [ + 'kind' => 'select_role', + 'roles' => array_values($roleNames), + 'redirect_url' => $this->urls->webSelectRoleUrl(false), + ]; + } +} diff --git a/app/Services/Auth/CodeIgniterApiRegistrationService.php b/app/Services/Auth/CodeIgniterApiRegistrationService.php new file mode 100644 index 00000000..e2ba7321 --- /dev/null +++ b/app/Services/Auth/CodeIgniterApiRegistrationService.php @@ -0,0 +1,126 @@ +all(); + if ($request->isJson() && ($requestData === [] || $requestData === null)) { + $decoded = json_decode((string) $request->getContent(), true); + $requestData = is_array($decoded) ? $decoded : []; + } + + $validator = Validator::make($requestData, [ + 'firstname' => ['required', 'string', 'min:2', 'max:30'], + 'lastname' => ['required', 'string', 'min:2', 'max:30'], + 'email' => ['required', 'email'], + 'password' => ['required', 'string', 'min:8'], + 'cellphone' => ['required', 'string', 'min:10', 'max:20'], + 'gender' => ['nullable', 'string'], + 'address_street' => ['nullable', 'string'], + 'city' => ['nullable', 'string'], + 'state' => ['nullable', 'string'], + 'zip' => ['nullable', 'string'], + 'role' => ['nullable', 'string'], + ]); + + if ($validator->fails()) { + return response()->json([ + 'status' => false, + 'message' => 'Validation failed', + 'errors' => $validator->errors()->toArray(), + ], 422); + } + + /** @var array $data */ + $data = $validator->validated(); + + if (User::query()->where('email', $data['email'])->exists()) { + return response()->json([ + 'status' => false, + 'message' => 'Email already registered', + ], 422); + } + + $schoolYear = Configuration::getConfig('school_year'); + $semester = Configuration::getConfig('semester'); + + $userData = [ + 'firstname' => $data['firstname'], + 'lastname' => $data['lastname'], + 'email' => $data['email'], + 'password' => pbkdf2_hash((string) $data['password']), + 'cellphone' => $data['cellphone'], + 'gender' => $data['gender'] ?? null, + 'address_street' => $data['address_street'] ?? '', + 'city' => $data['city'] ?? '', + 'state' => $data['state'] ?? '', + 'zip' => $data['zip'] ?? '', + 'school_year' => $schoolYear, + 'semester' => $semester ?? '', + 'status' => 'active', + 'is_verified' => 0, + 'accept_school_policy' => 0, + ]; + + try { + $userId = null; + $rolesForResponse = []; + + DB::transaction(function () use ($userData, $data, &$userId, &$rolesForResponse): void { + $user = User::query()->create($userData); + $userId = (int) $user->id; + + if (($data['role'] ?? '') === 'parent') { + $role = Role::query()->where('name', 'parent')->first(); + if ($role) { + UserRole::query()->create([ + 'user_id' => $userId, + 'role_id' => (int) $role->id, + ]); + $rolesForResponse = ['parent']; + } + } + }); + + $user = User::query()->findOrFail($userId); + $token = JWTAuth::fromUser($user); + + $fullName = trim((string) $user->firstname.' '.(string) $user->lastname); + + return response()->json([ + 'status' => true, + 'message' => 'Registration successful. Please verify your email.', + 'token' => $token, + 'user' => [ + 'id' => $userId, + 'name' => $fullName, + 'email' => $userData['email'], + 'roles' => $rolesForResponse, + ], + ], 201); + } catch (\Throwable $e) { + log_message('error', 'Registration error: '.$e->getMessage()); + + return response()->json([ + 'status' => false, + 'message' => 'Registration failed. Please try again.', + ], 500); + } + } +} diff --git a/app/Services/Auth/RegistrationService.php b/app/Services/Auth/RegistrationService.php index 4db45a72..94eb036c 100644 --- a/app/Services/Auth/RegistrationService.php +++ b/app/Services/Auth/RegistrationService.php @@ -7,6 +7,7 @@ use App\Models\ParentModel; use App\Models\Role; use App\Models\User; use App\Models\UserRole; +use App\Services\ApplicationUrlService; use App\Services\EmailService; use App\Services\SchoolIdService; use Illuminate\Support\Facades\DB; @@ -17,7 +18,8 @@ class RegistrationService private EmailService $emailService, private SchoolIdService $schoolIdService, private RegistrationFormatterService $formatter, - private RegistrationCaptchaService $captchaService + private RegistrationCaptchaService $captchaService, + private ApplicationUrlService $urls, ) { } @@ -125,26 +127,13 @@ class RegistrationService } }); - $activationLink = url('/user/confirm/' . $token); + $activationLink = $this->urls->spaRegistrationConfirmUrl($token); $recipientName = trim((string) ($formatted['firstname'] ?? '') . ' ' . (string) ($formatted['lastname'] ?? '')); - $emailData = [ - 'recipientName' => $recipientName, - 'activationLink' => $activationLink, - 'orgName' => 'Al Rahma Sunday School', - 'contactInfo' => 'alrahma.isgl@gmail.com', - 'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png', - 'subject' => 'Email Confirmation', - ]; - - if (view()->exists($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff')) { - $html = view($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff', $emailData); - } else { - $html = '

Hello ' . e($recipientName !== '' ? $recipientName : 'there') . ',

' - . '

Please confirm your email by clicking the link below:

' - . '

Activate your account

' - . '

Thank you.

'; - } + $html = '

Hello ' . e($recipientName !== '' ? $recipientName : 'there') . ',

' + . '

Please confirm your email by clicking the link below:

' + . '

Activate your account

' + . '

Thank you.

'; $sent = $this->emailService->send($email, 'Email Confirmation', $html, 'general'); $this->captchaService->clear(); diff --git a/app/Services/BadgeScan/BadgeScanService.php b/app/Services/BadgeScan/BadgeScanService.php new file mode 100644 index 00000000..283e427a --- /dev/null +++ b/app/Services/BadgeScan/BadgeScanService.php @@ -0,0 +1,107 @@ + false, + 'message' => 'Badge scan value is required.', + ]; + } + + if (! Schema::hasTable('scan_log')) { + return [ + 'recognized' => false, + 'message' => 'Scan logging is not available.', + ]; + } + + $user = User::query()->where('rfid_tag', $tag)->first(); + if (! $user) { + return [ + 'recognized' => false, + 'message' => 'Badge scan not recognized.', + ]; + } + + $displayName = trim(($user->firstname ?? '').' '.($user->lastname ?? '')); + if ($displayName === '') { + $displayName = $user->email ?? ('User #'.$user->id); + } + + DB::table('scan_log')->insert([ + 'user_id' => $user->id, + 'card_id' => $tag, + 'scan_time' => now(), + 'school_year' => Configuration::getConfigValueByKey('school_year'), + 'semester' => Configuration::getConfigValueByKey('semester'), + ]); + + return [ + 'recognized' => true, + 'message' => 'Badge recognized: '.$displayName, + 'user_id' => (int) $user->id, + 'display_name' => $displayName, + ]; + } + + /** + * Scan log listing (legacy CI RFIDController::log() query shape). + * + * @return list> + */ + public function scanLogRows(): array + { + if (! Schema::hasTable('scan_log')) { + return []; + } + + return DB::table('scan_log') + ->select([ + 'scan_log.id', + 'scan_log.user_id', + 'scan_log.card_id', + 'scan_log.scan_time', + 'scan_log.school_year', + 'scan_log.semester', + 'users.firstname as user_firstname', + 'users.lastname as user_lastname', + 'students.firstname as student_firstname', + 'students.lastname as student_lastname', + ]) + ->leftJoin('users', 'users.id', '=', 'scan_log.user_id') + ->leftJoin('students', 'students.rfid_tag', '=', 'scan_log.card_id') + ->orderByDesc('scan_log.scan_time') + ->limit(self::LOG_LIMIT) + ->get() + ->map(function ($row) { + $r = (array) $row; + foreach (['scan_time'] as $k) { + if (isset($r[$k]) && $r[$k] !== null && ! is_string($r[$k])) { + $r[$k] = (string) $r[$k]; + } + } + + return $r; + }) + ->all(); + } +} diff --git a/app/Services/Badges/BadgePdfService.php b/app/Services/Badges/BadgePdfService.php index b3ab3ece..15498267 100644 --- a/app/Services/Badges/BadgePdfService.php +++ b/app/Services/Badges/BadgePdfService.php @@ -1,157 +1,233 @@ */ + private array $tempQrFiles = []; + public function __construct( - protected BadgeUserLookupService $lookupService, + protected BadgeStudentLookupService $studentLookupService, + protected BadgeUserLookupService $userLookupService, protected BadgePrintLogService $printLogService, - protected BadgeTextFormatter $formatter + protected BadgeTextFormatter $formatter, ) { } + /** + * Letter landscape PDF, 8 badges per page — students and/or staff (FPDF + QR PNG). + * + * @param int[] $studentIds Primary keys on `students.id` + * @param int[] $userIds Staff/admin/teacher keys on `users.id` + */ public function generate( + array $studentIds, array $userIds, ?string $schoolYear, array $rolesMap = [], array $classesMap = [], ?int $actorId = null ): Response { - $userIds = $this->formatter->normalizeIds($userIds); + $this->tempQrFiles = []; - $pdf = new FPDF('P', 'mm', 'A4'); - $pdf->SetAutoPageBreak(false); - - $badgeW = 95; - $badgeH = 67; - $marginL = 14; - $marginT = 6; - $gutterX = 0; - $gutterY = 0; - $cols = 2; - $rows = 4; - $perPage = $cols * $rows; - - $pagesAdded = 0; - $i = 0; - $seen = []; - - foreach ($userIds as $uid) { - $info = $this->lookupService->getUserInfoById($uid, $schoolYear); - - if (!$info || !is_array($info)) { - continue; + try { + if (!class_exists('FPDF', false)) { + require_once base_path('app/ThirdParty/fpdf/fpdf.php'); } - $userId = (int) ($info['user_id'] ?? $uid); - $name = $this->formatter->norm($info['name'] ?? ''); + $studentIds = $this->formatter->normalizeIds($studentIds); + $userIds = $this->formatter->normalizeIds($userIds); - $postedRole = $rolesMap[$userId] ?? null; - $roleResolved = $postedRole !== null - ? $postedRole - : $this->formatter->resolveRole($info); + $schoolName = (string) config('badges.school_name', 'Al Rahma Sunday School'); + $motto = (string) config('badges.motto', ''); + $studentRoleLabel = (string) config('badges.role_label', 'Student'); + $footerBlock = $this->footerBlock($schoolName); + $logoPath = $this->logoFilePath(); - $roleResolved = $this->formatter->formatRole((string) $roleResolved); - $info['role_resolved'] = $roleResolved; + $rowsWithQr = []; - if (!empty($classesMap[$userId])) { - $info['class_section_name'] = (string) $classesMap[$userId]; + $students = $this->studentLookupService->fetchForBadges($studentIds, $schoolYear); + foreach ($students as $row) { + try { + $png = $this->renderQrPng((string) $row['school_id']); + $tmpPath = $this->writeTempQrPng($png); + if ($tmpPath === null) { + continue; + } + + $rowsWithQr[] = array_merge($row, [ + '_badge_kind' => 'student', + 'role_subline' => $studentRoleLabel, + '_qr_tmp' => $tmpPath, + ]); + } catch (Throwable) { + continue; + } } - $class = $this->formatter->norm($info['class_section_name'] ?? ''); + foreach ($userIds as $uid) { + $staffRow = $this->buildStaffBadgeRow((int) $uid, $schoolYear, $rolesMap, $classesMap); + if ($staffRow === null) { + continue; + } - $badgeKey = strtolower(trim($userId . '|' . $name . '|' . $roleResolved . '|' . $class)); - if (isset($seen[$badgeKey])) { - continue; - } - $seen[$badgeKey] = true; + try { + $qrPayload = (string) ($staffRow['user_id_for_qr'] ?? $staffRow['user_id'] ?? ''); + if ($qrPayload === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $qrPayload)) { + $qrPayload = (string) (int) ($staffRow['user_id'] ?? 0); + } - if (($i % $perPage) === 0) { - $pdf->AddPage(); - $pagesAdded++; + $png = $this->renderQrPng($qrPayload); + $tmpPath = $this->writeTempQrPng($png); + if ($tmpPath === null) { + continue; + } + + $rowsWithQr[] = array_merge($staffRow, [ + '_badge_kind' => 'staff', + '_qr_tmp' => $tmpPath, + ]); + } catch (Throwable) { + continue; + } } - $indexOnPage = $i % $perPage; - $row = intdiv($indexOnPage, $cols); - $col = $indexOnPage % $cols; + $pdf = new \FPDF('L', 'in', 'Letter'); + $pdf->SetAutoPageBreak(false); - $x = $marginL + $col * ($badgeW + $gutterX); - $y = $marginT + $row * ($badgeH + $gutterY); + $pages = array_chunk($rowsWithQr, 8); - $this->drawBadgeInCell($pdf, $info, $x, $y, $badgeW, $badgeH, $schoolYear); - $i++; + if ($pages === []) { + $pdf->AddPage('L', 'Letter'); + $pdf->SetFont('Helvetica', '', 12); + $pdf->SetTextColor(0, 0, 0); + $pdf->SetXY(self::PAGE_MARGIN_IN, self::PAGE_MARGIN_IN); + $pdf->MultiCell( + $pdf->GetPageWidth() - 2 * self::PAGE_MARGIN_IN, + 0.3, + $this->formatter->toPdf( + 'No valid badges: check user/student ids, roles, or (for students) school id for QR.' + ), + 0, + 'L' + ); + } else { + foreach ($pages as $pageRows) { + $pdf->AddPage('L', 'Letter'); + $slots = array_pad($pageRows, 8, null); + + for ($gridRow = 0; $gridRow < 2; $gridRow++) { + for ($gridCol = 0; $gridCol < 4; $gridCol++) { + $idx = ($gridRow * 4) + $gridCol; + $row = $slots[$idx]; + if (!is_array($row) || empty($row['_qr_tmp'])) { + continue; + } + + $cellX = self::PAGE_MARGIN_IN + $gridCol * self::CELL_WIDTH_IN; + $cellY = self::PAGE_MARGIN_IN + $gridRow * self::CELL_HEIGHT_IN; + + $badgeX = $cellX + (self::CELL_WIDTH_IN - self::BADGE_WIDTH_IN) / 2; + $badgeY = $cellY + (self::CELL_HEIGHT_IN - self::BADGE_HEIGHT_IN) / 2; + + $this->drawBadge( + $pdf, + $row, + $badgeX, + $badgeY, + self::BADGE_WIDTH_IN, + self::BADGE_HEIGHT_IN, + $schoolName, + $motto, + $studentRoleLabel, + $footerBlock, + $logoPath + ); + } + } + } + } + + $binary = $pdf->Output('S'); + + $logIds = array_values(array_unique(array_merge($studentIds, $userIds))); + $this->printLogService->logSafely( + userIds: $logIds, + actorId: $actorId, + schoolYear: $schoolYear, + rolesMap: $rolesMap, + classesMap: $classesMap, + copies: 1 + ); + + return response((string) $binary, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="Badges.pdf"', + ]); + } finally { + foreach ($this->tempQrFiles as $path) { + if (is_string($path) && $path !== '' && is_file($path)) { + @unlink($path); + } + } + $this->tempQrFiles = []; } - - if ($pagesAdded === 0) { - $pdf->AddPage(); - $pdf->SetFont('Arial', '', 10); - $pdf->SetXY(10, 20); - $pdf->MultiCell(0, 6, 'No valid staff selected or data not found.', 0, 'L'); - } - - $pdfString = $pdf->Output('S'); - - $this->printLogService->logSafely( - userIds: $userIds, - actorId: $actorId, - schoolYear: $schoolYear, - rolesMap: $rolesMap, - classesMap: $classesMap, - copies: 1 - ); - - return response($pdfString, 200, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline; filename="Staff_Badges.pdf"', - ]); } - protected function drawBadgeInCell(FPDF $pdf, array $data, float $x, float $y, float $w, float $h, ?string $schoolYear): void - { - $pdf->SetFillColor(255, 255, 255); - $pdf->Rect($x, $y, $w, $h, 'F'); - $pdf->SetDrawColor(200, 200, 200); - $pdf->Rect($x, $y, $w, $h); - - $logoSize = 6; - if (!empty($data['school_logo']) && file_exists($data['school_logo'])) { - $pdf->Image($data['school_logo'], $x + $w - 6 - $logoSize, $y + 4, $logoSize + 3, $logoSize - 1); - } - if (!empty($data['isgl_logo']) && file_exists($data['isgl_logo'])) { - $pdf->Image($data['isgl_logo'], $x + 4, $y + 4, $logoSize + 3, $logoSize - 1); + /** + * @param array $rolesMap + * @param array $classesMap + * @return ?array + */ + protected function buildStaffBadgeRow( + int $userId, + ?string $schoolYear, + array $rolesMap, + array $classesMap + ): ?array { + $info = $this->userLookupService->getUserInfoById($userId, $schoolYear); + if (!$info || !is_array($info)) { + return null; } - $padX = 4; - $cursorY = $y + 4 + $logoSize + 2; + $resolvedId = (int) ($info['user_id'] ?? $userId); - $schoolName = strtoupper($this->formatter->norm($data['school_name'] ?? 'AL RAHMA SUNDAY SCHOOL')); - $pdf->SetFont('Arial', '', 16); - $pdf->SetXY($x + $padX, $cursorY); - $pdf->MultiCell($w - 2 * $padX, 5, $this->formatter->toPdf($schoolName), 0, 'C'); + $postedRole = $rolesMap[$resolvedId] ?? null; + $roleResolved = $postedRole !== null + ? (string) $postedRole + : $this->formatter->resolveRole($info); - $pdf->Ln(9); - $pdf->SetFont('Arial', 'B', 14); - $pdf->SetX($x + $padX); - $name = strtoupper($this->formatter->norm($data['name'] ?? 'STAFF')); - $pdf->Cell($w - 2 * $padX, 6, $this->formatter->toPdf($name), 0, 1, 'C'); + $roleResolved = $this->formatter->formatRole($roleResolved); + $info['role_resolved'] = $roleResolved; - $roleResolved = $this->formatter->norm((string) ($data['role_resolved'] ?? '')); - if ($roleResolved === '') { - $roleResolved = $this->formatter->resolveRole($data); - } - if ($roleResolved !== '') { - $roleResolved = $this->formatter->formatRole($roleResolved); + if (!empty($classesMap[$resolvedId])) { + $info['class_section_name'] = (string) $classesMap[$resolvedId]; } - $classRaw = $this->formatter->norm($data['class_section_name'] ?? ''); - $class = $this->formatter->formatClass($classRaw); + $class = $this->formatter->norm($info['class_section_name'] ?? ''); + $class = $this->formatter->formatClass($class); $detectSrc = strtolower($this->formatter->norm( - ($data['roles_raw'] ?? '') !== '' ? $data['roles_raw'] : (($data['role_name_raw'] ?? '') !== '' ? $data['role_name_raw'] : $roleResolved) + ($info['roles'] ?? '') !== '' ? (string) $info['roles'] : $roleResolved )); $isTeacherish = str_contains($detectSrc, 'teacher') || preg_match('/\bta\b/', $detectSrc); @@ -171,26 +247,220 @@ class BadgePdfService $display = 'STAFF'; } - $pdf->Ln(6); - $pdf->SetFont('Arial', '', 14); - $displayOut = $this->formatter->toPdf($display); - $maxTextWidth = $w - 2 * $padX; - $best = $this->formatter->fitText($pdf, $displayOut, 11, 7, $maxTextWidth); - $pdf->SetFont('Arial', '', $best + 4); + $gradeCol = $class !== '' ? $class : '—'; + $year = $this->formatter->norm((string) ($info['school_year'] ?? ($schoolYear ?? ''))); - if ($pdf->GetStringWidth($displayOut) <= $maxTextWidth) { - $pdf->SetX($x + $padX); - $pdf->Cell($maxTextWidth, 5, $displayOut, 0, 1, 'C'); - } else { - $pdf->SetX($x + $padX); - $pdf->MultiCell($maxTextWidth, 5, $displayOut, 0, 'C'); + return [ + 'user_id' => $resolvedId, + 'user_id_for_qr' => (string) $resolvedId, + 'fullname' => $info['name'] ?? 'STAFF', + 'grade' => $gradeCol, + 'academic_year' => $year !== '' ? $year : '—', + 'school_id' => (string) $resolvedId, + 'role_subline' => $display, + ]; + } + + protected function writeTempQrPng(string $png): ?string + { + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'badge_qr_' . bin2hex(random_bytes(8)) . '.png'; + if (@file_put_contents($tmpPath, $png) === false) { + return null; } - $footerYear = $this->formatter->norm((string) ($schoolYear ?? ($data['school_year'] ?? ''))); - if ($footerYear !== '') { - $pdf->SetFont('Arial', 'I', 14); - $pdf->SetXY($x + $padX, $y + $h - 9); - $pdf->Cell($w - 2 * $padX, 4, $this->formatter->toPdf($footerYear), 0, 0, 'C'); + $this->tempQrFiles[] = $tmpPath; + + return $tmpPath; + } + + /** + * @param array $student + */ + protected function drawBadge( + \FPDF $pdf, + array $student, + float $x, + float $y, + float $w, + float $h, + string $schoolName, + string $motto, + string $defaultStudentRoleLabel, + string $footerBlock, + ?string $logoPath + ): void { + $kind = (string) ($student['_badge_kind'] ?? 'student'); + $idLabel = $kind === 'staff' ? 'USER ID' : 'SCHOOL ID'; + + $blue = [11, 58, 130]; + $blueLight = [27, 76, 160]; + + $qrPath = (string) ($student['_qr_tmp'] ?? ''); + $fullName = $this->formatter->toPdf((string) ($student['fullname'] ?? '')); + $grade = $this->formatter->toPdf((string) ($student['grade'] ?? '')); + $year = $this->formatter->toPdf((string) ($student['academic_year'] ?? '')); + $schoolId = $this->formatter->toPdf((string) ($student['school_id'] ?? '')); + + $schoolNamePdf = $this->formatter->toPdf($schoolName); + $mottoPdf = $this->formatter->toPdf($motto); + $rolePdf = $this->formatter->toPdf((string) ($student['role_subline'] ?? $defaultStudentRoleLabel)); + + $pdf->SetDrawColor($blue[0], $blue[1], $blue[2]); + $pdf->SetLineWidth(0.003); + $pdf->Rect($x, $y, $w, $h); + + $headerH = 0.55; + $footerH = 0.38; + + $pdf->SetFillColor($blue[0], $blue[1], $blue[2]); + $pdf->Rect($x, $y, $w, $headerH, 'F'); + + $logoSize = 0.12; + if ($logoPath !== null && is_readable($logoPath)) { + try { + $pdf->Image($logoPath, $x + $w / 2 - $logoSize / 2, $y + 0.035, $logoSize, $logoSize); + } catch (Throwable) { + } + } + + $pdf->SetTextColor(255, 255, 255); + $pdf->SetFont('Helvetica', 'B', 8.5); + $pdf->SetXY($x + 0.06, $y + 0.035 + $logoSize + 0.01); + $pdf->Cell($w - 0.12, 0.1, $schoolNamePdf, 0, 0, 'C'); + + if ($mottoPdf !== '') { + $pdf->SetFont('Helvetica', 'B', 5.2); + $pdf->SetXY($x + 0.06, $y + $headerH - 0.12); + $pdf->Cell($w - 0.12, 0.08, $mottoPdf, 0, 0, 'C'); + } + + $bodyTop = $y + $headerH + 0.06; + $pdf->SetTextColor(11, 47, 115); + $pdf->SetFont('Helvetica', 'B', 9.5); + $pdf->SetXY($x + 0.07, $bodyTop); + $pdf->Cell($w - 0.14, 0.11, strtoupper($fullName), 0, 1, 'C'); + + $pdf->SetTextColor(27, 86, 177); + $pdf->SetFont('Helvetica', 'B', 6); + $pdf->SetX($x + 0.07); + $pdf->Cell($w - 0.14, 0.07, strtoupper($rolePdf), 0, 1, 'C'); + + $ruleY = $bodyTop + 0.22; + $pdf->SetDrawColor(215, 222, 234); + $pdf->Line($x + 0.08, $ruleY, $x + $w - 0.08, $ruleY); + + $infoLeft = $x + 0.08; + $infoW = $w * 0.52; + $labelY = $ruleY + 0.05; + + $gradeLabel = $kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE'; + + $pdf->SetTextColor(51, 51, 51); + $pdf->SetFont('Helvetica', 'B', 5); + $pdf->SetXY($infoLeft, $labelY); + $pdf->Cell($infoW, 0.05, $gradeLabel, 0, 1, 'L'); + + $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); + $pdf->SetFont('Helvetica', 'B', 7.5); + $pdf->SetX($infoLeft); + $pdf->Cell($infoW, 0.08, $grade, 0, 1, 'L'); + + $pdf->SetTextColor(51, 51, 51); + $pdf->SetFont('Helvetica', 'B', 5); + $pdf->SetX($infoLeft); + $pdf->Cell($infoW, 0.05, 'ACADEMIC YEAR', 0, 1, 'L'); + + $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); + $pdf->SetFont('Helvetica', 'B', 7.5); + $pdf->SetX($infoLeft); + $pdf->Cell($infoW, 0.08, $year, 0, 1, 'L'); + + $pdf->SetTextColor(51, 51, 51); + $pdf->SetFont('Helvetica', 'B', 5); + $pdf->SetX($infoLeft); + $pdf->Cell($infoW, 0.05, $idLabel, 0, 1, 'L'); + + $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); + $pdf->SetFont('Helvetica', 'B', 7.5); + $pdf->SetX($infoLeft); + $pdf->MultiCell($infoW, 0.09, $schoolId, 0, 'L'); + + $qrSize = 0.38; + $qrX = $x + $w - 0.08 - $qrSize; + $qrY = $ruleY + 0.04; + + if ($qrPath !== '' && is_file($qrPath)) { + try { + $pdf->SetDrawColor($blueLight[0], $blueLight[1], $blueLight[2]); + $pdf->Rect($qrX - 0.03, $qrY - 0.03, $qrSize + 0.06, $qrSize + 0.06); + $pdf->Image($qrPath, $qrX, $qrY, $qrSize, $qrSize, 'PNG'); + } catch (Throwable) { + } + } + + $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); + $pdf->SetFont('Helvetica', 'B', 4.5); + $pdf->SetXY($qrX - 0.05, $qrY + $qrSize + 0.02); + $pdf->Cell($qrSize + 0.1, 0.05, 'SCAN TO VERIFY', 0, 0, 'C'); + + $fy = $y + $h - $footerH; + $pdf->SetFillColor($blue[0], $blue[1], $blue[2]); + $pdf->Rect($x, $fy, $w, $footerH, 'F'); + + $smallLogo = 0.1; + if ($logoPath !== null && is_readable($logoPath)) { + try { + $pdf->Image($logoPath, $x + $w / 2 - $smallLogo / 2, $fy + 0.03, $smallLogo, $smallLogo); + } catch (Throwable) { + } + } + + $pdf->SetTextColor(255, 255, 255); + $pdf->SetFont('Helvetica', 'B', 4.5); + $lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock]; + $lineY = $fy + 0.03 + $smallLogo + 0.015; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + $pdf->SetXY($x + 0.04, $lineY); + $pdf->Cell($w - 0.08, 0.055, $this->formatter->toPdf($line), 0, 1, 'C'); + $lineY += 0.055; } } -} \ No newline at end of file + + protected function renderQrPng(string $payload): string + { + $qrOptions = new QROptions([ + 'version' => 5, + 'outputInterface' => QRGdImagePNG::class, + 'outputBase64' => false, + 'eccLevel' => QRCode::ECC_M, + 'scale' => 4, + ]); + + $qr = new QRCode($qrOptions); + + return $qr->render($payload); + } + + protected function logoFilePath(): ?string + { + return $this->formatter->firstExisting([ + public_path('assets/images/school_logo.png'), + public_path('assets/images/logo.png'), + ]); + } + + protected function footerBlock(string $schoolName): string + { + $address = trim((string) config('badges.footer_address', '')); + if ($address !== '') { + return $schoolName . "\n" . $address; + } + + return $schoolName; + } +} diff --git a/app/Services/Badges/BadgeStudentLookupService.php b/app/Services/Badges/BadgeStudentLookupService.php new file mode 100644 index 00000000..cebbcc54 --- /dev/null +++ b/app/Services/Badges/BadgeStudentLookupService.php @@ -0,0 +1,78 @@ + + */ + public function fetchForBadges(array $studentIds, ?string $schoolYear): array + { + $studentIds = $this->formatter->normalizeIds($studentIds); + if ($studentIds === []) { + return []; + } + + $rows = Student::query() + ->whereIn('id', $studentIds) + ->where('is_active', 1) + ->orderBy('lastname') + ->orderBy('firstname') + ->get(); + + $out = []; + foreach ($rows as $student) { + $schoolId = trim((string) ($student->school_id ?? '')); + if ($schoolId === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $schoolId)) { + continue; + } + + $fullname = Student::getFullName($student->toArray()); + $grade = $this->resolveGrade((int) $student->id, (string) ($student->registration_grade ?? '')); + + $academicYear = $schoolYear ?? (string) ($student->school_year ?? ''); + $academicYear = $this->formatter->norm($academicYear); + + $out[] = [ + 'student_id' => (int) $student->id, + 'fullname' => $fullname !== '' ? $fullname : 'Student', + 'grade' => $grade, + 'academic_year' => $academicYear, + 'school_id' => $schoolId, + ]; + } + + return $out; + } + + protected function resolveGrade(int $studentId, string $registrationGrade): string + { + $registrationGrade = $this->formatter->norm($registrationGrade); + if ($registrationGrade !== '') { + return $registrationGrade; + } + + try { + $g = StudentClass::getStudentGrade($studentId); + $g = $this->formatter->norm((string) $g); + + return $g !== '' ? $g : '—'; + } catch (\Throwable) { + return '—'; + } + } +} diff --git a/app/Services/BroadcastEmail/BroadcastEmailComposerService.php b/app/Services/BroadcastEmail/BroadcastEmailComposerService.php index 538e2719..a0361dd3 100644 --- a/app/Services/BroadcastEmail/BroadcastEmailComposerService.php +++ b/app/Services/BroadcastEmail/BroadcastEmailComposerService.php @@ -2,7 +2,7 @@ namespace App\Services\BroadcastEmail; -use Illuminate\Support\Facades\View; +use App\Support\MailHtml; class BroadcastEmailComposerService { @@ -30,16 +30,6 @@ class BroadcastEmailComposerService return $content; } - if (!View::exists('emails.broadcast_wrapper')) { - return $content; - } - - return view('emails.broadcast_wrapper', [ - 'subject' => $subject, - 'content' => $content, - 'preheader' => $preheader, - 'cta_text' => $ctaText, - 'cta_url' => $ctaUrl, - ])->render(); + return MailHtml::broadcastCompose($subject, $content, $preheader, $ctaText, $ctaUrl); } } diff --git a/app/Services/ClassProgress/ClassProgressMetaService.php b/app/Services/ClassProgress/ClassProgressMetaService.php index 5a80d458..0b424289 100644 --- a/app/Services/ClassProgress/ClassProgressMetaService.php +++ b/app/Services/ClassProgress/ClassProgressMetaService.php @@ -2,7 +2,9 @@ namespace App\Services\ClassProgress; +use App\Models\Configuration; use App\Models\SubjectCurriculum; +use App\Services\SemesterRangeService; class ClassProgressMetaService { @@ -16,22 +18,126 @@ class ClassProgressMetaService return (array) config('progress.status_options', []); } + /** + * Fallback: next N Sundays from today (CI `buildUpcomingSundayOptions`). + * + * @return list + */ public function sundayOptions(int $count = 12): array { - $start = now(); - if ((int) $start->format('w') !== 0) { - $start = $start->next('Sunday'); + return $this->buildUpcomingSundayOptions($count); + } + + /** + * CI-aligned Sundays within semester/school-year range when configuration allows. + * + * @return list + */ + public function sundayOptionsAlignedWithCi(int $count = 12): array + { + $range = $this->resolveProgressDateRange(); + if ($range === null) { + return $this->buildUpcomingSundayOptions($count); + } + + [$rangeStart, $rangeEnd] = $range; + + try { + $start = new \DateTime($rangeStart); + $end = new \DateTime($rangeEnd); + } catch (\Exception $e) { + return $this->buildUpcomingSundayOptions($count); + } + + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + + $weekday = (int) $start->format('w'); + if ($weekday !== 0) { + $start->modify('next sunday'); + } + + $options = []; + while ($start <= $end) { + $options[] = $start->format('Y-m-d'); + $start->modify('+7 days'); + } + + return $options !== [] ? $options : $this->buildUpcomingSundayOptions($count); + } + + /** CI `pickDefaultWeekStart`. */ + public function pickDefaultWeekStart(array $options): string + { + if ($options === []) { + return ''; + } + + $today = date('Y-m-d'); + $default = ''; + foreach ($options as $option) { + if ($option <= $today) { + $default = $option; + + continue; + } + break; + } + + return $default !== '' ? $default : $options[0]; + } + + /** + * @return list + */ + private function buildUpcomingSundayOptions(int $count): array + { + $start = new \DateTime('today'); + $weekday = (int) $start->format('w'); + if ($weekday !== 0) { + $start->modify('next sunday'); } $options = []; for ($i = 0; $i < $count; $i++) { $options[] = $start->format('Y-m-d'); - $start = $start->addDays(7); + $start->modify('+7 days'); } return $options; } + /** + * CI `resolveProgressDateRange`. + * + * @return array{0:string,1:string}|null + */ + private function resolveProgressDateRange(): ?array + { + $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + if ($schoolYear === '') { + return null; + } + + /** @var SemesterRangeService $svc */ + $svc = app(SemesterRangeService::class); + + $semester = $svc->normalizeSemester((string) (Configuration::getConfig('semester') ?? '')); + if ($semester === '') { + $semester = $svc->getSemesterForDate(); + } + + if ($semester !== '') { + $range = $svc->getSemesterRange($schoolYear, $semester); + if ($range !== null) { + return $range; + } + } + + return $svc->getSchoolYearRange($schoolYear); + } + public function curriculumOptions(?int $classId): array { if (!$classId) { diff --git a/app/Services/ClassProgress/ClassProgressMutationService.php b/app/Services/ClassProgress/ClassProgressMutationService.php index 9ba34b9f..1edc4e9b 100644 --- a/app/Services/ClassProgress/ClassProgressMutationService.php +++ b/app/Services/ClassProgress/ClassProgressMutationService.php @@ -2,10 +2,11 @@ namespace App\Services\ClassProgress; +use App\Models\ClassProgressAttachment; use App\Models\ClassProgressReport; use App\Models\TeacherClass; use App\Models\User; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -13,7 +14,8 @@ class ClassProgressMutationService { public function __construct( private ClassProgressRuleService $rules, - private ClassProgressAttachmentService $attachments + private ClassProgressAttachmentService $attachments, + private ClassProgressQueryService $queries, ) {} public function createReports(User $user, array $payload, array $filesBySubject): Collection @@ -30,7 +32,31 @@ class ClassProgressMutationService throw new \InvalidArgumentException('Week end must be the same as or after the week start.'); } - $reports = DB::transaction(function () use ($user, $payload, $subjectSections, $classSectionId, $weekStart, $weekEnd, $filesBySubject) { + if (!$this->rules->hasIslamicUnitSelection($payload)) { + throw new \InvalidArgumentException('Please select at least one Islamic Studies unit.'); + } + + $confirmOverwrite = filter_var($payload['confirm_overwrite'] ?? false, FILTER_VALIDATE_BOOLEAN); + $existingIds = ClassProgressReport::query() + ->where('class_section_id', $classSectionId) + ->whereDate('week_start', $weekStart) + ->where('teacher_id', $user->id) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->filter() + ->values() + ->all(); + + if ($existingIds !== [] && !$confirmOverwrite) { + throw new \InvalidArgumentException('CONFIRM_OVERWRITE'); + } + + $reports = DB::transaction(function () use ($user, $payload, $subjectSections, $classSectionId, $weekStart, $weekEnd, $filesBySubject, $confirmOverwrite, $existingIds) { + if ($existingIds !== [] && $confirmOverwrite) { + ClassProgressAttachment::query()->whereIn('report_id', $existingIds)->delete(); + ClassProgressReport::query()->whereIn('id', $existingIds)->delete(); + } + $created = collect(); foreach ($subjectSections as $slug => $section) { @@ -83,6 +109,160 @@ class ClassProgressMutationService return $reports; } + /** + * CI `ClassProgressController::update` — sync all subjects for the weekly batch keyed by {@see $anchor}. + * + * @return Collection + */ + public function syncWeeklyFromTeacherForm(User $user, ClassProgressReport $anchor, array $payload, array $filesBySubject): Collection + { + $subjectSections = (array) config('progress.subject_sections', []); + $classSectionId = (int) ($payload['class_section_id'] ?? $anchor->class_section_id); + + $this->assertTeacherAssignment($user, $classSectionId); + + $weekStart = (string) ($payload['week_start'] ?? ''); + $weekEnd = $this->rules->ensureWeekEnd($weekStart, $payload['week_end'] ?? null); + + if (! $this->rules->isWeekEndValid($weekStart, $weekEnd)) { + throw new \InvalidArgumentException('Week end must be the same as or after the week start.'); + } + + if (! $this->rules->hasIslamicUnitSelection($payload)) { + throw new \InvalidArgumentException('Please select at least one Islamic Studies unit.'); + } + + $originalWeekStart = $anchor->week_start instanceof \Carbon\CarbonInterface + ? $anchor->week_start->format('Y-m-d') + : (string) $anchor->week_start; + + $confirmOverwrite = filter_var($payload['confirm_overwrite'] ?? false, FILTER_VALIDATE_BOOLEAN); + + if ($weekStart !== '' && $weekStart !== $originalWeekStart) { + $conflicts = ClassProgressReport::query() + ->where('class_section_id', $classSectionId) + ->whereDate('week_start', $weekStart) + ->where('teacher_id', $user->id) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->filter() + ->values() + ->all(); + + if ($conflicts !== [] && ! $confirmOverwrite) { + throw new \InvalidArgumentException('CONFIRM_OVERWRITE'); + } + + if ($conflicts !== [] && $confirmOverwrite) { + ClassProgressAttachment::query()->whereIn('report_id', $conflicts)->delete(); + ClassProgressReport::query()->whereIn('id', $conflicts)->delete(); + } + } + + $allowedIds = $this->queries->resolveAssignedTeacherIds($classSectionId); + if ($allowedIds === []) { + $allowedIds = [(int) $user->id]; + } + + return DB::transaction(function () use ( + $user, + $anchor, + $payload, + $subjectSections, + $classSectionId, + $weekStart, + $weekEnd, + $filesBySubject, + $allowedIds, + $originalWeekStart, + ): Collection { + $weeklyBatch = ClassProgressReport::query() + ->whereIn('teacher_id', $allowedIds) + ->where('class_section_id', $classSectionId) + ->whereDate('week_start', $originalWeekStart) + ->orderBy('subject') + ->get(); + + $reportMap = []; + foreach ($weeklyBatch as $rep) { + $subject = (string) ($rep->subject ?? ''); + if ($subject !== '') { + $reportMap[$subject] = $rep; + } + } + + $updated = collect(); + $flagsPresent = array_key_exists('flags', $payload); + + foreach ($subjectSections as $slug => $section) { + $covered = trim((string) ($payload["covered_{$slug}"] ?? '')); + if ($covered === '') { + continue; + } + + $homework = trim((string) ($payload["homework_{$slug}"] ?? '')); + $unitValues = (array) ($payload["unit_{$slug}"] ?? []); + $chapterValues = (array) ($payload["chapter_{$slug}"] ?? []); + $unitTitle = $this->rules->buildUnitChapterSummary($unitValues, $chapterValues); + + $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug; + $existing = $reportMap[$subjectName] ?? null; + + if ($unitTitle === null && $existing) { + $unitTitle = $existing->unit_title; + } + + $data = [ + 'class_section_id' => $classSectionId, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'subject' => $subjectName, + 'unit_title' => $unitTitle, + 'covered' => $covered, + 'homework' => $homework !== '' ? $homework : null, + ]; + + if ($flagsPresent) { + $data['flags_json'] = $this->rules->normalizeFlags($payload['flags'] ?? null); + } + + if ($existing) { + $existing->fill($data); + $existing->save(); + $saved = $existing->fresh(); + } else { + $data['teacher_id'] = $user->id; + $data['status'] = $this->rules->defaultStatus(); + $saved = ClassProgressReport::query()->create($data); + } + + if (! $saved) { + continue; + } + + $updated->push($saved); + + $attachments = $filesBySubject[$slug] ?? []; + $stored = $this->attachments->storeAttachments((int) $saved->id, $attachments); + if ($stored !== [] && ! $saved->attachment_path) { + $saved->update(['attachment_path' => $stored[0]['file_path'] ?? null]); + } + } + + if ($updated->isEmpty()) { + throw new \InvalidArgumentException('Please provide progress for at least one subject.'); + } + + Log::info('Class progress weekly sync completed.', [ + 'teacher_id' => $user->id, + 'anchor_id' => $anchor->id, + 'count' => $updated->count(), + ]); + + return $updated; + }); + } + public function updateReport(ClassProgressReport $report, array $payload, array $attachments): ClassProgressReport { return DB::transaction(function () use ($report, $payload, $attachments) { diff --git a/app/Services/ClassProgress/ClassProgressQueryService.php b/app/Services/ClassProgress/ClassProgressQueryService.php index a090d656..119f9f9d 100644 --- a/app/Services/ClassProgress/ClassProgressQueryService.php +++ b/app/Services/ClassProgress/ClassProgressQueryService.php @@ -4,16 +4,13 @@ namespace App\Services\ClassProgress; use App\Models\ClassProgressAttachment; use App\Models\ClassProgressReport; +use App\Models\Configuration; use App\Models\TeacherClass; use App\Models\User; use Illuminate\Contracts\Pagination\LengthAwarePaginator; class ClassProgressQueryService { - public function __construct( - private ClassProgressRuleService $rules - ) {} - public function listReports(User $user, array $filters): LengthAwarePaginator { $query = ClassProgressReport::query() @@ -21,8 +18,13 @@ class ClassProgressQueryService ->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc'); if (!$this->isAdmin($user)) { - $query->where('teacher_id', $user->id); - } elseif (!empty($filters['teacher_id'])) { + if (! empty($filters['class_section_id'])) { + $ids = $this->resolveAssignedTeacherIds((int) $filters['class_section_id']); + $query->whereIn('teacher_id', $ids !== [] ? $ids : [(int) $user->id]); + } else { + $query->where('teacher_id', $user->id); + } + } elseif (! empty($filters['teacher_id'])) { $query->where('teacher_id', (int) $filters['teacher_id']); } @@ -53,12 +55,17 @@ class ClassProgressQueryService public function getReport(User $user, int $reportId): ClassProgressReport { - $query = ClassProgressReport::query()->with('classSection'); - if (!$this->isAdmin($user)) { - $query->where('teacher_id', $user->id); + $report = ClassProgressReport::query()->with('classSection')->findOrFail($reportId); + + if ($this->isAdmin($user)) { + return $report; } - return $query->findOrFail($reportId); + if ($this->viewerMayAccessReport($user, $report)) { + return $report; + } + + abort(404); } public function weeklyReports(User $user, ClassProgressReport $report): array @@ -69,8 +76,9 @@ class ClassProgressQueryService ->whereDate('week_start', $report->week_start) ->orderBy('subject', 'asc'); - if (!$this->isAdmin($user)) { - $query->where('teacher_id', $user->id); + if (! $this->isAdmin($user)) { + $ids = $this->resolveAssignedTeacherIds((int) $report->class_section_id); + $query->whereIn('teacher_id', $ids !== [] ? $ids : [(int) $user->id]); } $rows = $query->get(); @@ -137,4 +145,40 @@ class ClassProgressQueryService return false; } + + /** + * CI `ClassProgressController::resolveAssignedTeacherIds`. + * + * @return list + */ + public function resolveAssignedTeacherIds(int $classSectionId): array + { + $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + if ($schoolYear === '' || $classSectionId <= 0) { + return []; + } + + $semester = (string) (Configuration::getConfig('semester') ?? ''); + $rows = TeacherClass::assignedForSectionTerm($classSectionId, $semester, $schoolYear); + $ids = []; + foreach ($rows as $row) { + $id = (int) ($row['teacher_id'] ?? 0); + if ($id > 0) { + $ids[$id] = true; + } + } + + return array_map('intval', array_keys($ids)); + } + + private function viewerMayAccessReport(User $user, ClassProgressReport $report): bool + { + if ((int) $report->teacher_id === (int) $user->id) { + return true; + } + + $ids = $this->resolveAssignedTeacherIds((int) $report->class_section_id); + + return $ids !== [] && in_array((int) $user->id, $ids, true); + } } diff --git a/app/Services/ClassProgress/ClassProgressRuleService.php b/app/Services/ClassProgress/ClassProgressRuleService.php index e6fbd985..f3542b54 100644 --- a/app/Services/ClassProgress/ClassProgressRuleService.php +++ b/app/Services/ClassProgress/ClassProgressRuleService.php @@ -4,6 +4,9 @@ namespace App\Services\ClassProgress; class ClassProgressRuleService { + /** CI `ClassProgressController::CUSTOM_UNIT_ROW_LABEL` — must match teacher form JS. */ + public const CUSTOM_UNIT_ROW_LABEL = 'Custom'; + public function defaultStatus(): string { return 'on_track'; @@ -15,6 +18,12 @@ class ClassProgressRuleService return $values === [] ? null : $values; } + /** + * CI `buildUnitChapterSummary` — Custom / typed chapter rows preserved. + * + * @param array $unitValues + * @param array $chapterValues + */ public function buildUnitChapterSummary(array $unitValues, array $chapterValues): ?string { $parts = []; @@ -25,9 +34,12 @@ class ClassProgressRuleService if ($unit === '' && $chapter === '') { continue; } + if (strcasecmp($unit, self::CUSTOM_UNIT_ROW_LABEL) === 0 && $chapter !== '') { + $unit = self::CUSTOM_UNIT_ROW_LABEL; + } $segment = $unit; if ($chapter !== '') { - $segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter; + $segment = $segment !== '' ? $unit.' / '.$chapter : $chapter; } if ($segment === '') { continue; @@ -40,9 +52,25 @@ class ClassProgressRuleService } $summary = implode(' ; ', $parts); + return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary; } + /** CI `hasIslamicUnitSelection`. */ + public function hasIslamicUnitSelection(array $payload): bool + { + $unitValues = array_map('trim', (array) ($payload['unit_islamic'] ?? [])); + $chapterValues = array_map('trim', (array) ($payload['chapter_islamic'] ?? [])); + $count = max(count($unitValues), count($chapterValues)); + for ($i = 0; $i < $count; $i++) { + if (($unitValues[$i] ?? '') !== '' || ($chapterValues[$i] ?? '') !== '') { + return true; + } + } + + return false; + } + public function ensureWeekEnd(string $weekStart, ?string $weekEnd): string { if ($weekEnd) { @@ -62,4 +90,47 @@ class ClassProgressRuleService { return strtotime($weekEnd) >= strtotime($weekStart); } + + /** + * CI `parseUnitChapterSummary` — inverse of {@see buildUnitChapterSummary()} for edit forms. + * + * @return array{units: list, chapters: list} + */ + public function parseUnitChapterSummary(string $summary): array + { + $summary = trim($summary); + if ($summary === '') { + return ['units' => [], 'chapters' => []]; + } + + $units = []; + $chapters = []; + $segments = preg_split('/\s*;\s*/', $summary, -1, PREG_SPLIT_NO_EMPTY); + foreach ($segments as $segment) { + $segment = trim((string) $segment); + if ($segment === '') { + continue; + } + if (preg_match('/^Custom\s*\/\s*(.+)$/iu', $segment, $m)) { + $units[] = self::CUSTOM_UNIT_ROW_LABEL; + $chapters[] = trim($m[1]); + + continue; + } + $parts = preg_split('/\s*\/\s*/', $segment, 2); + if (count($parts) === 2) { + $u = trim($parts[0]); + if (strcasecmp($u, self::CUSTOM_UNIT_ROW_LABEL) === 0) { + $u = self::CUSTOM_UNIT_ROW_LABEL; + } + $units[] = $u; + $chapters[] = trim($parts[1]); + } else { + $units[] = $segment; + $chapters[] = ''; + } + } + + return ['units' => $units, 'chapters' => $chapters]; + } } diff --git a/app/Services/Discounts/DiscountInvoiceService.php b/app/Services/Discounts/DiscountInvoiceService.php index 4928794e..12bec3d5 100644 --- a/app/Services/Discounts/DiscountInvoiceService.php +++ b/app/Services/Discounts/DiscountInvoiceService.php @@ -9,11 +9,17 @@ use App\Models\Enrollment; use App\Models\EventCharges; use App\Models\Invoice; use App\Models\Payment; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class DiscountInvoiceService { + public function __construct( + private ApplicationUrlService $urls, + ) { + } + public function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float { $invoice = Invoice::query()->find($invoiceId); @@ -347,7 +353,7 @@ class DiscountInvoiceService 'invoice_total' => (float) $invoice->total_amount, 'pre_balance' => $preBalance, 'post_balance' => $postBalance, - 'portalLink' => url('parent/invoices/' . $invoiceId), + 'portalLink' => $this->urls->spaParentInvoiceDetailUrl($invoiceId), ]; return [$data, $studentRows]; diff --git a/app/Services/Docs/ApiDocsService.php b/app/Services/Docs/ApiDocsService.php new file mode 100644 index 00000000..8dd3ecf5 --- /dev/null +++ b/app/Services/Docs/ApiDocsService.php @@ -0,0 +1,45 @@ + + */ + public function bootstrap(?User $user, bool $public): array + { + return [ + 'variant' => $public ? 'public' : 'full', + 'title' => $public + ? 'Al Rahma API — Public Docs' + : 'Al Rahma Sunday School — API Docs', + 'openapi_url' => $this->urls->docsSwaggerMergedJsonUrl(false), + 'try_it_out_enabled' => ! $public, + 'persist_authorization' => ! $public, + 'show_authorize_button' => ! $public, + 'welcome_name' => $public ? null : $this->welcomeName($user), + ]; + } + + private function welcomeName(?User $user): ?string + { + if (! $user) { + return null; + } + + $full = trim(($user->firstname ?? '').' '.($user->lastname ?? '')); + + return $full !== '' ? $full : ($user->email ?: 'Admin'); + } +} diff --git a/app/Services/Docs/DocsCatalogService.php b/app/Services/Docs/DocsCatalogService.php new file mode 100644 index 00000000..0cffc811 --- /dev/null +++ b/app/Services/Docs/DocsCatalogService.php @@ -0,0 +1,66 @@ + + */ + public function indexPayload(): array + { + return [ + 'title' => (string) config('docs.index.title', 'API documentation'), + 'description' => (string) config('docs.index.description', ''), + 'links' => [ + 'swagger_catalog' => $this->urls->docsSwaggerCatalogUrl(), + 'merged_openapi_json' => $this->urls->docsSwaggerMergedJsonUrl(), + 'docs_spa' => config('docs.client_url'), + 'public_bootstrap' => $this->urls->apiDocsPublicBootstrapUrl(), + 'admin_bootstrap' => $this->urls->apiDocsAdminBootstrapUrl(), + ], + ]; + } + + /** + * Multi-spec dropdown payload (CI `View\DocsController::swagger` / `swagger_ui` view). + * + * @return array{specs: list, merged_openapi_url: string} + */ + public function swaggerSpecsPayload(): array + { + $specs = []; + foreach ((array) config('docs.swagger_specs', []) as $row) { + if (! is_array($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? '')); + $path = trim((string) ($row['path'] ?? '')); + if ($name === '' || $path === '') { + continue; + } + $specs[] = [ + 'name' => $name, + 'url' => $this->urls->absolutePublicPathUrl($path), + ]; + } + + return [ + 'specs' => $specs, + 'merged_openapi_url' => $this->urls->docsSwaggerMergedJsonUrl(), + ]; + } +} diff --git a/app/Services/Expenses/ExpenseReceiptService.php b/app/Services/Expenses/ExpenseReceiptService.php index d7e5e044..cb7b5c39 100644 --- a/app/Services/Expenses/ExpenseReceiptService.php +++ b/app/Services/Expenses/ExpenseReceiptService.php @@ -2,10 +2,16 @@ namespace App\Services\Expenses; +use App\Services\ApplicationUrlService; use Illuminate\Http\UploadedFile; class ExpenseReceiptService { + public function __construct( + private ApplicationUrlService $urls + ) { + } + public function storeReceipt(UploadedFile $file): string { $stored = $file->store('receipts'); @@ -14,9 +20,6 @@ class ExpenseReceiptService public function receiptUrl(?string $filename): ?string { - if (!$filename) { - return null; - } - return url('receipts/' . $filename); + return $this->urls->forReceiptStorage($filename); } } diff --git a/app/Services/ExtraCharges/ExtraChargesChargeService.php b/app/Services/ExtraCharges/ExtraChargesChargeService.php index af80c2a0..2d1f549f 100644 --- a/app/Services/ExtraCharges/ExtraChargesChargeService.php +++ b/app/Services/ExtraCharges/ExtraChargesChargeService.php @@ -5,6 +5,7 @@ namespace App\Services\ExtraCharges; use App\Models\AdditionalCharge; use App\Models\Invoice; use App\Models\User; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -12,7 +13,8 @@ class ExtraChargesChargeService { public function __construct( private ExtraChargesContextService $context, - private ExtraChargesInvoiceService $invoiceService + private ExtraChargesInvoiceService $invoiceService, + private ApplicationUrlService $urls, ) {} public function createCharge(array $payload): array @@ -197,8 +199,10 @@ class ExtraChargesChargeService 'pre_balance' => $preBalance, 'post_balance' => $postBalance, 'invoice_total' => $invoiceTotal, - 'portal_link' => url('/login'), - 'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'), + 'portal_link' => $this->urls->webLoginUrl(), + 'invoice_link' => $invoiceId + ? $this->urls->spaParentInvoiceViewUrl($invoiceId) + : $this->urls->spaParentInvoicesIndexUrl(), ]; $students = []; diff --git a/app/Services/Frontend/LandingPageParentDashboardService.php b/app/Services/Frontend/LandingPageParentDashboardService.php index 04b4b260..67aee08d 100644 --- a/app/Services/Frontend/LandingPageParentDashboardService.php +++ b/app/Services/Frontend/LandingPageParentDashboardService.php @@ -4,13 +4,15 @@ namespace App\Services\Frontend; use App\Models\Invoice; use App\Models\StudentClass; +use App\Services\Parents\PrimaryParentUserResolver; use Illuminate\Support\Facades\DB; class LandingPageParentDashboardService { - public function __construct(private LandingPageContextService $context) - { - } + public function __construct( + private LandingPageContextService $context, + private PrimaryParentUserResolver $primaryParentResolver, + ) {} public function summary(int $parentUserId, string $userType): array { @@ -18,7 +20,7 @@ class LandingPageParentDashboardService $schoolYear = (string) ($context['school_year'] ?? ''); $semester = (string) ($context['semester'] ?? ''); - $parentId = $this->resolveParentId($parentUserId, $userType); + $parentId = $this->primaryParentResolver->resolveFromCredentials($parentUserId, $userType); if (!$parentId) { return [ 'ok' => false, @@ -89,31 +91,6 @@ class LandingPageParentDashboardService ]; } - private function resolveParentId(int $userId, string $userType): ?int - { - if ($userType === 'primary') { - return $userId; - } - - if ($userType === 'secondary') { - $row = DB::table('parents') - ->select('parent_id') - ->where('secondparent_user_id', $userId) - ->first(); - return $row ? (int) $row->parent_id : null; - } - - if ($userType === 'tertiary') { - $row = DB::table('authorized_users') - ->select('user_id as parent_id') - ->where('authorized_user_id', $userId) - ->first(); - return $row ? (int) $row->parent_id : null; - } - - return null; - } - private function notificationsForParent(int $parentId): array { return DB::table('notifications') diff --git a/app/Services/Grading/BelowSixtyEmailService.php b/app/Services/Grading/BelowSixtyEmailService.php index 677b21e7..906c2a55 100644 --- a/app/Services/Grading/BelowSixtyEmailService.php +++ b/app/Services/Grading/BelowSixtyEmailService.php @@ -2,6 +2,7 @@ namespace App\Services\Grading; +use App\Support\MailHtml; use App\Services\EmailService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -84,11 +85,7 @@ class BelowSixtyEmailService $html = trim((string) ($payload['html'] ?? '')); if ($html === '') { - try { - $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); - } catch (\Throwable $e) { - $html = '

' . e($subject) . '

'; - } + $html = MailHtml::document($subject, MailHtml::belowSixtyInnerHtml($emailData)); } $sent = 0; diff --git a/app/Services/Parents/AuthorizedUsersManagementService.php b/app/Services/Parents/AuthorizedUsersManagementService.php new file mode 100644 index 00000000..d764635a --- /dev/null +++ b/app/Services/Parents/AuthorizedUsersManagementService.php @@ -0,0 +1,166 @@ +hashToken($plainToken); + + return AuthorizedUser::query() + ->where(function ($q) use ($hash, $plainToken) { + $q->where('token', $hash)->orWhere('token', $plainToken); + }) + ->where('created_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS)) + ->first(); + } + + /** + * CI setPassword / savePassword — active row with rotating token. + */ + public function findActiveSetupRow(int $authorizedUserId, string $plainToken): ?AuthorizedUser + { + if ($plainToken === '') { + return null; + } + + $hash = $this->hashToken($plainToken); + + return AuthorizedUser::query() + ->where(function ($q) use ($hash, $plainToken) { + $q->where('token', $hash)->orWhere('token', $plainToken); + }) + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS)) + ->first(); + } + + /** + * @return array{redirect_url: string} + */ + public function confirmEmailClick(string $plainToken): array + { + $row = $this->findPendingByIncomingToken($plainToken); + if (! $row) { + throw new \InvalidArgumentException('Invalid or expired confirmation link.'); + } + + $nextPlain = bin2hex(random_bytes(48)); + $nextHash = $this->hashToken($nextPlain); + + $row->status = 'Active'; + $row->token = $nextHash; + $row->save(); + + $url = $this->urls->inviteSetPasswordFormUrl((int) $row->authorized_user_id, $nextPlain); + + return ['redirect_url' => $url]; + } + + /** + * CI `create` — when email is not a registered user, respond success without leaking (privacy). + * + * @return array{created: bool, record: AuthorizedUser|null} + */ + public function inviteByEmail(int $primaryUserId, string $email): array + { + $email = strtolower(trim($email)); + + $user = User::query() + ->whereRaw('LOWER(email) = ?', [$email]) + ->first(); + + if (! $user) { + return ['created' => false, 'record' => null]; + } + + $plain = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($plain); + + $phone = trim((string) ($user->cellphone ?? '')); + $record = AuthorizedUser::query()->create([ + 'user_id' => $primaryUserId, + 'authorized_user_id' => (int) $user->id, + 'firstname' => (string) ($user->firstname ?? ''), + 'lastname' => (string) ($user->lastname ?? ''), + 'phone_number' => $phone !== '' ? $phone : '-', + 'gender' => trim((string) ($user->gender ?? '')) !== '' ? (string) $user->gender : '-', + 'email' => $email, + 'relation_to_user' => null, + 'token' => $tokenHash, + 'status' => 'Pending', + ]); + + $this->sendConfirmationEmail($email, $plain); + + return ['created' => true, 'record' => $record]; + } + + public function sendConfirmationEmail(string $email, string $plainToken): void + { + $confirmLink = $this->urls->inviteConfirmUrl($plainToken); + $body = '

You have been added as an authorized user for another account. Click the link below to confirm your access:

' + .'

Confirm Access

' + .'

If you did not request this, please ignore this email.

'; + + $this->mail->send($email, 'Authorized User Confirmation', $body, 'notifications'); + } + + /** + * @throws \InvalidArgumentException + */ + public function setAuthorizedUserPassword(int $authorizedUserId, int $postedUserId, string $plainToken, string $password): void + { + if ($postedUserId !== $authorizedUserId || $authorizedUserId <= 0) { + throw new \InvalidArgumentException('Invalid request.'); + } + + $row = $this->findActiveSetupRow($authorizedUserId, $plainToken); + if (! $row) { + throw new \InvalidArgumentException('Invalid or expired confirmation link.'); + } + + $user = User::query()->find($authorizedUserId); + if (! $user) { + throw new \InvalidArgumentException('User not found.'); + } + + DB::transaction(function () use ($user, $row, $password) { + $user->password = $password; + $user->save(); + + $row->token = null; + $row->save(); + }); + } +} diff --git a/app/Services/Parents/ParentAttendanceReportService.php b/app/Services/Parents/ParentAttendanceReportService.php index 6bf87f45..fba576f8 100644 --- a/app/Services/Parents/ParentAttendanceReportService.php +++ b/app/Services/Parents/ParentAttendanceReportService.php @@ -4,10 +4,12 @@ namespace App\Services\Parents; use App\Models\AttendanceData; use App\Models\AttendanceRecord; +use App\Models\EarlyDismissalSignature; use App\Models\ParentAttendanceReport; use App\Models\Student; use App\Models\ClassSection; use App\Services\SemesterRangeService; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; class ParentAttendanceReportService @@ -254,12 +256,12 @@ class ParentAttendanceReportService ]; } - public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester): array + public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester, int $parentId): array { - return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester); + return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester, $parentId); } - public function checkExisting(array $payload): array + public function checkExisting(int $parentId, array $payload): array { $datesPost = $payload['dates'] ?? null; $dateSingle = $payload['date'] ?? null; @@ -286,6 +288,15 @@ class ParentAttendanceReportService return []; } + $uniqueIds = array_values(array_unique($studentIds)); + $ownedCount = Student::query() + ->where('parent_id', $parentId) + ->whereIn('id', $uniqueIds) + ->count(); + if ($ownedCount !== count($uniqueIds)) { + throw new \RuntimeException('Invalid student selection.'); + } + $rows = DB::table('parent_attendance_reports as par') ->select('par.student_id', 'par.type', 'par.report_date', 's.firstname', 's.lastname') ->leftJoin('students as s', 's.id', '=', 'par.student_id') @@ -371,6 +382,275 @@ class ParentAttendanceReportService $row->update($update); } + /** + * Staff index: early dismissal rows grouped by date (+ signature metadata per date). + * + * Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::earlyDismissals}. + * + * @return array{groups: array>>, school_year: string, semester: string, signature_by_date: array>} + */ + public function staffEarlyDismissalsIndex(?string $schoolYearParam, ?string $semesterParam): array + { + $context = $this->configService->context(); + $schoolYear = filled($schoolYearParam) ? trim((string) $schoolYearParam) : (string) ($context['school_year'] ?? ''); + $semester = filled($semesterParam) ? trim((string) $semesterParam) : ''; + + $b = DB::table('parent_attendance_reports as par') + ->select('par.*', 's.firstname', 's.lastname', 'cs.class_section_name') + ->leftJoin('students as s', 's.id', '=', 'par.student_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'par.class_section_id') + ->where('par.type', '=', 'early_dismissal'); + + if ($schoolYear !== '') { + $b->where('par.school_year', $schoolYear); + } + if ($semester !== '') { + $b->where('par.semester', $semester); + } + + $rows = $b->orderByDesc('par.report_date') + ->orderBy('par.dismiss_time') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + $groups = []; + foreach ($rows as $r) { + $d = substr((string) ($r['report_date'] ?? ''), 0, 10); + if ($d === '') { + $d = 'Unknown'; + } + $groups[$d][] = $r; + } + uksort($groups, static function ($a, $b) { + if ($a === 'Unknown' && $b === 'Unknown') { + return 0; + } + if ($a === 'Unknown') { + return 1; + } + if ($b === 'Unknown') { + return -1; + } + + return strcmp((string) $b, (string) $a); + }); + + $signatureByDate = []; + $dates = array_values(array_filter(array_keys($groups), static function ($d) { + return preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $d) === 1; + })); + if ($dates !== []) { + $sigQ = EarlyDismissalSignature::query()->whereIn('report_date', $dates); + if ($schoolYear !== '') { + $sigQ->where('school_year', $schoolYear); + } + if ($semester !== '') { + $sigQ->where('semester', $semester); + } + foreach ($sigQ->orderBy('report_date')->get() as $sig) { + $d = $sig->report_date instanceof \DateTimeInterface + ? $sig->report_date->format('Y-m-d') + : substr((string) $sig->report_date, 0, 10); + if ($d !== '') { + $signatureByDate[$d] = $sig->toArray(); + } + } + } + + return [ + 'groups' => $groups, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'signature_by_date' => $signatureByDate, + ]; + } + + /** + * Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::addEarlyDismissalForm}. + * + * @return array{students: array>, default_date: string, school_year: string, semester: string} + */ + public function staffAddEarlyDismissalFormData(): array + { + $context = $this->configService->context(); + $schoolYear = (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + try { + $students = DB::table('students as s') + ->select('s.id', 's.firstname', 's.lastname', 's.parent_id', 'cs.class_section_name', 'sc.class_section_id') + ->leftJoin('student_class as sc', function ($join) use ($schoolYear) { + $join->on('sc.student_id', '=', 's.id') + ->where('sc.school_year', '=', $schoolYear); + }) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->orderBy('s.lastname') + ->orderBy('s.firstname') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + } catch (\Throwable) { + $students = []; + } + + [, $defaultDate] = $this->calendarService->computeSundays(0, 26); + + return [ + 'students' => $students, + 'default_date' => $defaultDate, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + } + + /** + * Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::saveEarlyDismissal}. + */ + public function saveStaffEarlyDismissal(int $studentId, string $reportDate, string $dismissTime, string $reason): void + { + $context = $this->configService->context(); + $schoolYear = (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); + if (! $dt || (int) $dt->format('w') !== 0) { + throw new \RuntimeException('Please select a Sunday date.'); + } + + $stu = Student::query()->select('id', 'parent_id', 'firstname', 'lastname')->find($studentId); + if (! $stu || empty($stu->parent_id)) { + throw new \RuntimeException('Selected student not found or has no primary parent assigned.'); + } + $parentId = (int) $stu->parent_id; + + $sectionRow = DB::table('student_class') + ->select('class_section_id') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderByDesc('updated_at') + ->first(); + + $classSectionId = $sectionRow ? (int) $sectionRow->class_section_id : null; + + $qb = ParentAttendanceReport::query() + ->where('student_id', $studentId) + ->whereDate('report_date', $reportDate) + ->where('school_year', $schoolYear); + + $existingAl = (clone $qb)->where(function ($q) { + $q->where('type', 'absent')->orWhere('type', 'late'); + })->first(); + + if ($existingAl) { + throw new \RuntimeException('Absent/Late already submitted for this student on the selected date.'); + } + + $existingEd = (clone $qb)->where('type', 'early_dismissal')->first(); + + if ($existingEd) { + $existingEd->update([ + 'dismiss_time' => $dismissTime !== '' ? $dismissTime : $existingEd->dismiss_time, + 'reason' => $reason !== '' ? $reason : $existingEd->reason, + 'status' => 'new', + ]); + + return; + } + + ParentAttendanceReport::query()->create([ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'report_date' => $reportDate, + 'type' => 'early_dismissal', + 'dismiss_time' => $dismissTime, + 'reason' => $reason !== '' ? $reason : null, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'status' => 'new', + ]); + } + + /** + * Parity with CodeIgniter {@see \App\Controllers\View\ParentAttendanceReportController::uploadEarlyDismissalSignature}. + * Files live under {@see storage_path('uploads/early_dismissal_signatures')} (same as {@see \App\Http\Controllers\Api\Reports\FilesController::earlyDismissalSignature}). + */ + public function storeEarlyDismissalSignature( + UploadedFile $file, + string $reportDate, + ?string $schoolYearPost, + ?string $semesterPost, + int $uploadedByUserId, + ): void { + $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); + if (! $dt || (int) $dt->format('w') !== 0) { + throw new \RuntimeException('Please select a Sunday date.'); + } + + $context = $this->configService->context(); + $schoolYear = $schoolYearPost !== null && $schoolYearPost !== '' + ? (string) $schoolYearPost + : (string) ($context['school_year'] ?? ''); + $semesterRaw = (string) ($semesterPost ?? ''); + $hasSemesterFilter = $semesterRaw !== ''; + $semester = $hasSemesterFilter ? $semesterRaw : null; + + $existsQ = ParentAttendanceReport::query() + ->where('type', 'early_dismissal') + ->whereDate('report_date', $reportDate); + if ($schoolYear !== '') { + $existsQ->where('school_year', $schoolYear); + } + if ($hasSemesterFilter) { + $existsQ->where('semester', $semester); + } + if (! $existsQ->exists()) { + throw new \RuntimeException('No early dismissal records found for that date.'); + } + + $targetDir = storage_path('uploads/early_dismissal_signatures'); + if (! is_dir($targetDir) && ! @mkdir($targetDir, 0755, true) && ! is_dir($targetDir)) { + throw new \RuntimeException('Upload directory is not available.'); + } + + $originalName = $file->getClientOriginalName(); + $mimeType = $file->getClientMimeType(); + $fileSize = (int) $file->getSize(); + $filename = $file->hashName(); + $file->move($targetDir, $filename); + + $payload = [ + 'report_date' => $reportDate, + 'school_year' => $schoolYear !== '' ? $schoolYear : null, + 'semester' => $semester, + 'filename' => $filename, + 'original_name' => $originalName, + 'mime_type' => $mimeType, + 'file_size' => $fileSize, + 'uploaded_by' => $uploadedByUserId, + ]; + + $existingQ = EarlyDismissalSignature::query()->whereDate('report_date', $reportDate); + if ($schoolYear !== '') { + $existingQ->where('school_year', $schoolYear); + } + if ($hasSemesterFilter) { + $existingQ->where('semester', $semester); + } + $existing = $existingQ->first(); + + if ($existing) { + $oldFile = (string) ($existing->filename ?? ''); + $existing->update($payload); + if ($oldFile !== '' && $oldFile !== $filename) { + @unlink($targetDir . DIRECTORY_SEPARATOR . $oldFile); + } + } else { + EarlyDismissalSignature::query()->create($payload); + } + } + private function resolveClassSectionLabel(?int $classSectionId): string { $cid = (int) ($classSectionId ?? 0); diff --git a/app/Services/Parents/ParentProgressQueryService.php b/app/Services/Parents/ParentProgressQueryService.php new file mode 100644 index 00000000..5a229723 --- /dev/null +++ b/app/Services/Parents/ParentProgressQueryService.php @@ -0,0 +1,191 @@ +> + */ + public function parentStudents(int $parentId): array + { + if ($parentId <= 0) { + return []; + } + + $rows = DB::table('enrollments as e') + ->select( + 'e.student_id', + 'e.class_section_id', + 'e.updated_at', + 'e.created_at', + 's.firstname', + 's.lastname', + 'cs.class_section_name', + ) + ->join('students as s', 's.id', '=', 'e.student_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id') + ->where('e.parent_id', $parentId) + ->where('e.is_withdrawn', 0) + ->orderByDesc('e.updated_at') + ->orderByDesc('e.created_at') + ->get(); + + $students = []; + foreach ($rows as $row) { + $arr = (array) $row; + $studentId = (int) ($arr['student_id'] ?? 0); + if ($studentId === 0 || isset($students[$studentId])) { + continue; + } + $students[$studentId] = $arr; + } + + return array_values($students); + } + + /** + * CI `getParentSectionIds`. + * + * @return list + */ + public function parentSectionIds(int $parentId): array + { + if ($parentId <= 0) { + return []; + } + + $ids = DB::table('enrollments') + ->where('parent_id', $parentId) + ->where('is_withdrawn', 0) + ->whereNotNull('class_section_id') + ->distinct() + ->pluck('class_section_id') + ->map(fn ($id) => (int) $id) + ->filter(fn ($id) => $id > 0) + ->unique() + ->values() + ->all(); + + return array_values($ids); + } + + public function parentCanAccessSection(int $parentUserId, ?int $classSectionId): bool + { + if (! $classSectionId) { + return false; + } + + return in_array((int) $classSectionId, $this->parentSectionIds($parentUserId), true); + } + + /** + * Progress reports visible to the parent (all teachers) for allowed sections. + * + * @param list $sectionIds + */ + public function reportsForSections(array $sectionIds): Collection + { + $sectionIds = array_values(array_filter(array_map('intval', $sectionIds))); + if ($sectionIds === []) { + return collect(); + } + + return ClassProgressReport::query() + ->select('class_progress_reports.*') + ->addSelect([ + 'cs.class_section_name', + DB::raw('CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name'), + ]) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id') + ->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id') + ->whereIn('class_progress_reports.class_section_id', $sectionIds) + ->orderByDesc('class_progress_reports.week_start') + ->get(); + } + + /** + * CI `groupReportsByWeek` — keys `"{week_start}:{class_section_id}"`. + * + * @param iterable $rows + * @return array> + */ + public function groupReportsByWeek(iterable $rows): array + { + $statusOptions = (array) config('progress.status_options', []); + $reportGroups = []; + + foreach ($rows as $row) { + $statusLabel = $statusOptions[$row->status] ?? 'Unknown'; + $row->setAttribute('status_label', $statusLabel); + + $weekStart = $row->week_start instanceof \Carbon\CarbonInterface + ? $row->week_start->format('Y-m-d') + : (string) $row->week_start; + $sectionId = (int) $row->class_section_id; + if ($weekStart === '' || $sectionId === 0) { + continue; + } + + $key = $weekStart.':'.$sectionId; + if (! isset($reportGroups[$key])) { + $reportGroups[$key] = [ + 'week_start' => $weekStart, + 'week_end' => $row->week_end instanceof \Carbon\CarbonInterface + ? $row->week_end->format('Y-m-d') + : (string) ($row->week_end ?? ''), + 'class_section_name' => $row->class_section_name ?? '', + 'class_section_id' => $sectionId, + 'reports' => [], + ]; + } + + $subject = (string) ($row->subject ?? ''); + if ($subject === '') { + continue; + } + + $reportGroups[$key]['reports'][$subject] = (new \App\Http\Resources\ClassProgress\ClassProgressReportResource($row))->toArray(request()); + } + + return $reportGroups; + } + + /** + * Weekly batch for a section/week (CI parent `view`, all subjects). + * + * @return list + */ + public function weeklyReportsForSectionWeek(int $classSectionId, string $weekStartYmd): array + { + return ClassProgressReport::query() + ->with(['classSection', 'teacher']) + ->where('class_section_id', $classSectionId) + ->whereDate('week_start', $weekStartYmd) + ->orderBy('subject') + ->get() + ->all(); + } + + /** + * @return array> + */ + public function attachmentMapForReportIds(array $reportIds): array + { + return $this->classProgressQuery->attachmentMap($reportIds); + } +} diff --git a/app/Services/Parents/ParentReportCardService.php b/app/Services/Parents/ParentReportCardService.php new file mode 100644 index 00000000..b1d5db0c --- /dev/null +++ b/app/Services/Parents/ParentReportCardService.php @@ -0,0 +1,161 @@ +primaryParentResolver->resolveFromUser($user); + } + + /** + * CI index() student rows for parent + optional student_class term filters. + * + * @return list> + */ + public function studentsForTerm(int $primaryParentUserId, string $schoolYear, string $semester): array + { + if ($primaryParentUserId <= 0) { + return []; + } + + $q = DB::table('students as s') + ->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name') + ->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->where('s.parent_id', $primaryParentUserId) + ->orderBy('s.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC'); + + if ($schoolYear !== '') { + $q->where('sc.school_year', $schoolYear); + } + if ($semester !== '') { + $q->where('sc.semester', $semester); + } + + return $q->get()->map(static fn ($r) => (array) $r)->all(); + } + + /** + * @param list $studentIds + * @return array> + */ + public function acknowledgementMap(int $primaryParentUserId, array $studentIds, string $schoolYear, string $semester): array + { + if ($primaryParentUserId <= 0 || $studentIds === []) { + return []; + } + + $rows = ReportCardAcknowledgement::query() + ->where('parent_id', $primaryParentUserId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->whereIn('student_id', $studentIds) + ->get(); + + $map = []; + foreach ($rows as $row) { + $map[(int) $row->student_id] = $row->toArray(); + } + + return $map; + } + + public function studentBelongsToPrimaryParent(int $studentId, int $primaryParentUserId): bool + { + $pid = DB::table('students')->where('id', $studentId)->value('parent_id'); + + return $pid !== null && (int) $pid === $primaryParentUserId; + } + + /** + * @param array $values viewed_at, signed_at, signed_name, signer_ip, … + */ + public function touchAcknowledgement( + int $primaryParentUserId, + int $studentId, + string $schoolYear, + string $semester, + array $values, + ): void { + $criteria = [ + 'parent_id' => $primaryParentUserId, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + + $existing = ReportCardAcknowledgement::query()->where($criteria)->first(); + if ($existing) { + $existing->fill($values); + $existing->save(); + + return; + } + + ReportCardAcknowledgement::query()->create(array_merge($criteria, $values)); + } + + public function reportCardUrl(int $studentId, string $schoolYear, string $semester): string + { + $query = []; + if ($schoolYear !== '') { + $query['school_year'] = $schoolYear; + } + if ($semester !== '') { + $query['semester'] = $semester; + } + + $base = $this->urls->studentReportCardUrl($studentId); + + return $query === [] ? $base : $base.'?'.http_build_query($query); + } +} diff --git a/app/Services/Parents/PrimaryParentUserResolver.php b/app/Services/Parents/PrimaryParentUserResolver.php new file mode 100644 index 00000000..ac36967d --- /dev/null +++ b/app/Services/Parents/PrimaryParentUserResolver.php @@ -0,0 +1,54 @@ +resolveFromCredentials((int) $user->id, (string) ($user->user_type ?? '')); + } + + /** + * @param int $userId Session user id (may be secondary/tertiary login). + */ + public function resolveFromCredentials(int $userId, string $userType): ?int + { + $userType = strtolower(trim($userType)); + if ($userType === 'primary') { + return $userId > 0 ? $userId : null; + } + + if ($userType === 'secondary') { + $row = DB::table('parents') + ->select('parent_id') + ->where('secondparent_user_id', $userId) + ->first(); + + return $row ? (int) ($row->parent_id ?? 0) ?: null : null; + } + + if ($userType === 'tertiary') { + $row = DB::table('authorized_users') + ->select('user_id') + ->where('authorized_user_id', $userId) + ->first(); + + return $row ? (int) ($row->user_id ?? 0) ?: null : null; + } + + return $userId > 0 ? $userId : null; + } +} diff --git a/app/Services/Payments/PaymentEnrollmentEventService.php b/app/Services/Payments/PaymentEnrollmentEventService.php index fd2728ba..ad162049 100644 --- a/app/Services/Payments/PaymentEnrollmentEventService.php +++ b/app/Services/Payments/PaymentEnrollmentEventService.php @@ -3,10 +3,16 @@ namespace App\Services\Payments; use App\Models\Configuration; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\DB; class PaymentEnrollmentEventService { + public function __construct( + private ApplicationUrlService $urls, + ) { + } + public function buildEventData( int $parentId, array $studentIds, @@ -16,7 +22,7 @@ class PaymentEnrollmentEventService ): array { $schoolYear = $schoolYear ?: Configuration::getConfig('school_year'); $semester = $semester ?: Configuration::getConfig('semester'); - $portalLink = $portalLink ?: url('/login'); + $portalLink = $portalLink ?: $this->urls->webLoginUrl(); $parent = DB::table('users') ->select('id', 'firstname', 'lastname', 'email') diff --git a/app/Services/Payments/PaymentNotificationService.php b/app/Services/Payments/PaymentNotificationService.php index c9ad0fb7..935f88f1 100644 --- a/app/Services/Payments/PaymentNotificationService.php +++ b/app/Services/Payments/PaymentNotificationService.php @@ -6,6 +6,7 @@ use App\Models\Configuration; use App\Models\PaymentNotificationLog; use App\Models\User; use App\Services\EmailService; +use App\Support\MailHtml; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -255,14 +256,7 @@ class PaymentNotificationService HTML; - if (view()->exists('emails._wrap_layout')) { - $body = view('emails._wrap_layout', [ - 'title' => 'Monthly Tuition Reminder', - 'body_html' => $bodyHtml, - ], ['saveData' => true]); - } else { - $body = $bodyHtml; - } + $body = MailHtml::document('Monthly Tuition Reminder', $bodyHtml); return [$subject, $body]; } diff --git a/app/Services/Payments/PaymentTestNotificationService.php b/app/Services/Payments/PaymentTestNotificationService.php index a2765af5..1ab86176 100644 --- a/app/Services/Payments/PaymentTestNotificationService.php +++ b/app/Services/Payments/PaymentTestNotificationService.php @@ -8,6 +8,7 @@ use App\Models\Invoice; use App\Models\PaymentNotificationLog; use App\Models\User; use App\Services\EmailService; +use App\Support\MailHtml; use DateTimeImmutable; use DateTimeZone; @@ -84,14 +85,7 @@ class PaymentTestNotificationService . "
  • Maximum installments available: {$installmentInfo['max_installments']}
  • " . ""; - if (view()->exists('emails._wrap_layout')) { - $body = view('emails._wrap_layout', [ - 'title' => 'Monthly Tuition Reminder', - 'body_html' => $bodyHtml, - ], ['saveData' => true]); - } else { - $body = $bodyHtml; - } + $body = MailHtml::document('Monthly Tuition Reminder', $bodyHtml); $ok = $this->emailService->send($email, $subject, (string) $body, 'finance'); if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) { diff --git a/app/Services/Payments/PaypalPaymentService.php b/app/Services/Payments/PaypalPaymentService.php index a1ada492..359d573b 100644 --- a/app/Services/Payments/PaypalPaymentService.php +++ b/app/Services/Payments/PaypalPaymentService.php @@ -3,11 +3,17 @@ namespace App\Services\Payments; use App\Models\Payment; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class PaypalPaymentService { + public function __construct( + private ApplicationUrlService $urls, + ) { + } + public function getRedirectUrl(): string { return (string) (config('services.paypal.payment_url') @@ -50,8 +56,8 @@ class PaypalPaymentService ->setTransactions([$transaction]); $redirectUrls = new \PayPal\Api\RedirectUrls(); - $redirectUrls->setReturnUrl(url('/api/v1/finance/paypal/execute')) - ->setCancelUrl(url('/api/v1/finance/paypal/cancel')); + $redirectUrls->setReturnUrl($this->urls->paypalExecuteReturnUrl()) + ->setCancelUrl($this->urls->paypalCancelUrl()); $paypalPayment->setRedirectUrls($redirectUrls); $paypalPayment->create($apiContext); diff --git a/app/Services/PrintRequests/PrintRequestsPortalService.php b/app/Services/PrintRequests/PrintRequestsPortalService.php new file mode 100644 index 00000000..d547313a --- /dev/null +++ b/app/Services/PrintRequests/PrintRequestsPortalService.php @@ -0,0 +1,557 @@ +storageDirectory(); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + } + + /** + * @return list + */ + public function candidateAbsolutePaths(string $storedName): array + { + $safe = basename(trim($storedName)); + + return [ + $this->storageDirectory().DIRECTORY_SEPARATOR.$safe, + public_path('uploads/print_requests/'.$safe), + ]; + } + + public function resolveExistingFilePath(string $storedName): ?string + { + if (trim($storedName) === '') { + return null; + } + + foreach ($this->candidateAbsolutePaths($storedName) as $path) { + if (is_file($path)) { + return $path; + } + } + + return null; + } + + /** + * @return array{sundays: list, times: list} + */ + public function buildRequiredByOptions(): array + { + $tz = new \DateTimeZone('America/Chicago'); + $currentDate = new \DateTime('now', $tz); + $currentYear = (int) $currentDate->format('Y'); + $endYear = ($currentDate->format('n') > 6) ? $currentYear + 1 : $currentYear; + $endDate = new \DateTime("first Sunday of June {$endYear}", $tz); + $endDate->modify('+1 week'); + + $sundays = []; + $date = new \DateTime('now', $tz); + $date->setTime(0, 0, 0); + if ($date->format('w') !== '0') { + $date->modify('next Sunday'); + } + while ($date < $endDate) { + $sundays[] = $date->format('Y-m-d'); + $date->modify('+1 week'); + } + + $times = []; + $start = new \DateTime('10:00', $tz); + $end = new \DateTime('13:00', $tz); + $interval = new \DateInterval('PT15M'); + $period = new \DatePeriod($start, $interval, $end); + foreach ($period as $time) { + $times[] = $time->format('H:i'); + } + + return [ + 'sundays' => $sundays, + 'times' => $times, + ]; + } + + /** + * @return list> + */ + public function teacherPrintRequests(int $teacherId): array + { + return DB::table('print_requests as pr') + ->select('pr.*', 'admins.firstname as admin_firstname', 'admins.lastname as admin_lastname') + ->leftJoin('users as admins', 'admins.id', '=', 'pr.admin_id') + ->where('pr.teacher_id', $teacherId) + ->orderBy('pr.id', 'desc') + ->get() + ->map(fn ($r) => $this->normalizePrintRequestRow((array) $r)) + ->all(); + } + + /** + * Admin queue ordering per CI raw SQL. + * + * @return list> + */ + public function adminPrintRequests(): array + { + return DB::table('print_requests as pr') + ->select( + 'pr.*', + 'u.firstname', + 'u.lastname', + 'cs.class_section_name', + 'admins.firstname as admin_firstname', + 'admins.lastname as admin_lastname' + ) + ->leftJoin('users as u', 'u.id', '=', 'pr.teacher_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'pr.class_id') + ->leftJoin('users as admins', 'admins.id', '=', 'pr.admin_id') + ->orderByRaw("CASE + WHEN pr.status = 'not_assigned' THEN 1 + WHEN pr.status = 'assigned' THEN 2 + WHEN pr.status = 'done' THEN 3 + WHEN pr.status = 'delivered' THEN 4 + ELSE 5 + END ASC") + ->orderBy('pr.required_by', 'asc') + ->get() + ->map(fn ($r) => $this->normalizePrintRequestRow((array) $r)) + ->all(); + } + + public function defaultClassSectionIdForTeacher(int $teacherId): ?int + { + $row = DB::table('teacher_class as tc') + ->join('classSection as cs', 'tc.class_section_id', '=', 'cs.class_section_id') + ->where('tc.teacher_id', $teacherId) + ->whereNotNull('tc.class_section_id') + ->select('cs.class_section_id') + ->orderBy('tc.class_section_id') + ->first(); + + return $row ? (int) $row->class_section_id : null; + } + + /** + * @param array $requestData merged row after update for notifications + */ + public function notifyAdminsForPrintRequest(int $printRequestId, array $requestData, string $event = 'created'): void + { + try { + $event = strtolower(trim($event)); + if ( + ! Schema::hasTable('admin_notification_subjects') + || ! Schema::hasTable('notifications') + || ! Schema::hasTable('user_notifications') + ) { + return; + } + + $adminIds = AdminNotificationSubject::query() + ->where('subject', 'print_requests') + ->pluck('admin_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->filter(fn ($id) => $id > 0) + ->values() + ->all(); + + if ($adminIds === []) { + return; + } + + $teacherId = (int) ($requestData['teacher_id'] ?? 0); + $teacherName = ''; + if ($teacherId > 0) { + $teacher = User::query()->find($teacherId); + if ($teacher) { + $teacherName = trim(($teacher->firstname ?? '').' '.($teacher->lastname ?? '')); + } + } + if ($teacherName === '') { + $teacherName = $teacherId > 0 ? ('Teacher #'.$teacherId) : 'Teacher'; + } + + $className = ''; + $classId = $requestData['class_id'] ?? null; + if ($classId !== null && $classId !== '') { + $className = (string) (ClassSection::getClassSectionNameBySectionId((int) $classId) ?? ''); + } + + $eventMap = [ + 'created' => [ + 'title' => 'New Print Request', + 'intro' => 'A new print request has been submitted.', + 'lead' => 'New print request from ', + ], + 'updated' => [ + 'title' => 'Print Request Updated', + 'intro' => 'A print request has been updated.', + 'lead' => 'Updated print request from ', + ], + 'deleted' => [ + 'title' => 'Print Request Removed', + 'intro' => 'A print request has been deleted.', + 'lead' => 'Print request removed for ', + ], + ]; + $eventConfig = $eventMap[$event] ?? $eventMap['created']; + + $filePath = trim((string) ($requestData['file_path'] ?? '')); + $isCopyRequest = $filePath === ''; + + $messageParts = [$eventConfig['lead'].$teacherName]; + if ($className !== '') { + $messageParts[] = 'Class: '.$className; + } + if (! empty($requestData['num_copies'])) { + $messageParts[] = 'Copies: '.(int) $requestData['num_copies']; + } + if (! empty($requestData['required_by'])) { + $messageParts[] = 'Needed by: '.$requestData['required_by']; + } + if (! empty($requestData['page_selection'])) { + $messageParts[] = 'Pages: '.$requestData['page_selection']; + } + if (! empty($requestData['pickup_method'])) { + $messageParts[] = 'Pickup: '.$requestData['pickup_method']; + } + $message = implode(' | ', $messageParts); + + $actionUrl = $this->urls->spaAdminPrintRequestsUrl(); + + $payload = [ + 'title' => $eventConfig['title'], + 'message' => $message, + 'target_group' => 'admin', + 'delivery_channels' => 'in_app,email', + 'priority' => 'normal', + 'status' => 'pending', + 'action_url' => $isCopyRequest ? '' : $actionUrl, + 'scheduled_at' => function_exists('utc_now') ? utc_now() : now(), + 'school_year' => Configuration::getConfigValueByKey('school_year'), + 'semester' => Configuration::getConfigValueByKey('semester'), + ]; + + $notificationFields = Schema::getColumnListing('notifications'); + if ($notificationFields !== []) { + $payload = array_intersect_key($payload, array_flip($notificationFields)); + } + + $notificationId = (int) DB::table('notifications')->insertGetId(array_merge($payload, [ + 'created_at' => now(), + 'updated_at' => now(), + ])); + + if ($notificationId <= 0) { + return; + } + + $userNotificationFields = Schema::getColumnListing('user_notifications'); + $allowedUn = array_flip($userNotificationFields !== [] ? $userNotificationFields : []); + + foreach ($adminIds as $adminId) { + $row = [ + 'notification_id' => $notificationId, + 'user_id' => $adminId, + 'is_read' => 0, + 'delivered' => 0, + ]; + if ($allowedUn !== []) { + $row = array_intersect_key($row, $allowedUn); + } + DB::table('user_notifications')->insert($row); + } + + $adminRows = User::query() + ->select(['id', 'firstname', 'lastname', 'email']) + ->whereIn('id', $adminIds) + ->get(); + + $subject = $eventConfig['title']; + foreach ($adminRows as $adminRow) { + $email = trim((string) ($adminRow->email ?? '')); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $body = '

    '.e($eventConfig['intro']).'

    ' + .'

    From: '.e($teacherName).'

    ' + .($className !== '' ? '

    Class: '.e($className).'

    ' : '') + .(! empty($requestData['num_copies']) ? '

    Copies: '.(int) $requestData['num_copies'].'

    ' : '') + .(! empty($requestData['required_by']) ? '

    Needed by: '.e((string) $requestData['required_by']).'

    ' : '') + .(! empty($requestData['page_selection']) ? '

    Pages: '.e((string) $requestData['page_selection']).'

    ' : '') + .(! empty($requestData['pickup_method']) ? '

    Pickup Method: '.e((string) $requestData['pickup_method']).'

    ' : '') + .($isCopyRequest ? '' : '

    View print requests

    '); + + $this->mail->send($email, $subject, $body, 'notifications'); + } + } catch (\Throwable $e) { + logger()->error('Print request notification failed: '.$e->getMessage()); + } + } + + /** + * @param array $row + * @return array + */ + public function normalizePrintRequestRow(array $row): array + { + foreach (['required_by'] as $key) { + if (! isset($row[$key])) { + continue; + } + $v = $row[$key]; + if ($v instanceof CarbonInterface) { + $row[$key] = $v->format('Y-m-d H:i:s'); + } + } + + return $row; + } + + /** + * @return array> + */ + public static function allowedStatusTransitions(): array + { + return [ + 'not_assigned' => ['assigned'], + 'assigned' => ['not_assigned', 'done'], + 'done' => ['delivered'], + 'delivered' => [], + ]; + } + + public function teacherOwnsRequest(PrintRequest $request, int $teacherUserId): bool + { + return (int) $request->teacher_id === $teacherUserId; + } + + public function teacherMayEditOrDelete(PrintRequest $request): bool + { + return in_array((string) $request->status, ['not_assigned', 'assigned'], true); + } + + /** + * Teacher dashboard payload (parity with CI teacher_index). + * + * @return array{print_requests: list>, sundays: list, times: list, class_id: int|null} + */ + public function teacherBootstrap(int $teacherId): array + { + $opts = $this->buildRequiredByOptions(); + + return [ + 'print_requests' => $this->teacherPrintRequests($teacherId), + 'sundays' => $opts['sundays'], + 'times' => $opts['times'], + 'class_id' => $this->defaultClassSectionIdForTeacher($teacherId), + ]; + } + + /** + * @param array $data validated: class_id, page_selection?, num_copies, required_by, pickup_method + */ + public function storeTeacherUpload(array $data, UploadedFile $file, int $teacherId): PrintRequest + { + $this->ensureStorageDirectoryExists(); + $ext = $file->getClientOriginalExtension(); + $stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : ''); + $file->move($this->storageDirectory(), $stored); + + $row = [ + 'teacher_id' => $teacherId, + 'class_id' => (int) $data['class_id'], + 'file_path' => $stored, + 'page_selection' => isset($data['page_selection']) ? trim((string) $data['page_selection']) : null, + 'num_copies' => (int) $data['num_copies'], + 'required_by' => $data['required_by'], + 'pickup_method' => (string) $data['pickup_method'], + 'status' => 'not_assigned', + ]; + + $model = PrintRequest::query()->create($row); + $payload = array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]); + $this->notifyAdminsForPrintRequest((int) $model->id, $payload, 'created'); + + return $model->fresh(); + } + + /** + * @param array $data num_copies, required_by, pickup_method, page_selection? + */ + public function updateTeacherFields(PrintRequest $request, array $data, ?UploadedFile $newFile): PrintRequest + { + $update = [ + 'num_copies' => (int) $data['num_copies'], + 'required_by' => $data['required_by'], + 'pickup_method' => (string) $data['pickup_method'], + 'page_selection' => isset($data['page_selection']) ? trim((string) $data['page_selection']) : null, + ]; + + if ($newFile !== null) { + $this->ensureStorageDirectoryExists(); + $old = trim((string) ($request->file_path ?? '')); + if ($old !== '') { + foreach ($this->candidateAbsolutePaths($old) as $abs) { + if (is_file($abs)) { + @unlink($abs); + break; + } + } + } + $ext = $newFile->getClientOriginalExtension(); + $stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : ''); + $newFile->move($this->storageDirectory(), $stored); + $update['file_path'] = $stored; + } + + $request->update($update); + $merged = array_merge($request->toArray(), $update, ['id' => $request->id]); + $this->notifyAdminsForPrintRequest((int) $request->id, $merged, 'updated'); + + return $request->fresh(); + } + + public function applyAdminStatus(PrintRequest $request, string $newStatus, int $adminUserId): PrintRequest + { + $current = (string) $request->status; + $allowed = self::allowedStatusTransitions(); + if (! isset($allowed[$current]) || ! in_array($newStatus, $allowed[$current], true)) { + if ($current === $newStatus) { + return $request; + } + throw new \InvalidArgumentException('Invalid status transition.'); + } + + $data = ['status' => $newStatus]; + if ($newStatus === 'assigned') { + $data['admin_id'] = $adminUserId; + } elseif ($newStatus === 'not_assigned') { + $data['admin_id'] = null; + } + + $request->update($data); + + return $request->fresh(); + } + + public function deleteTeacherRequest(PrintRequest $request): void + { + $this->notifyAdminsForPrintRequest((int) $request->id, $request->toArray(), 'deleted'); + + $fp = trim((string) ($request->file_path ?? '')); + if ($fp !== '') { + foreach ($this->candidateAbsolutePaths($fp) as $abs) { + if (is_file($abs)) { + @unlink($abs); + break; + } + } + } + + $request->delete(); + } + + public function copyFromExisting(PrintRequest $source, int $teacherId): PrintRequest + { + $fp = trim((string) ($source->file_path ?? '')); + if ($fp === '' || $this->resolveExistingFilePath($fp) === null) { + throw new \InvalidArgumentException('The original file is unavailable for copying.'); + } + + $row = [ + 'teacher_id' => $teacherId, + 'class_id' => (int) $source->class_id, + 'file_path' => $fp, + 'page_selection' => $source->page_selection, + 'num_copies' => (int) $source->num_copies, + 'required_by' => $source->required_by, + 'pickup_method' => (string) $source->pickup_method, + 'status' => 'not_assigned', + ]; + + $model = PrintRequest::query()->create($row); + $this->notifyAdminsForPrintRequest((int) $model->id, array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]), 'created'); + + return $model->fresh(); + } + + /** + * Copy-center request without uploading a file (CI createCopy). + * + * @param array $data class_id, num_copies, required_by, pickup_method + */ + public function storeHandCopy(array $data, int $teacherId): PrintRequest + { + $row = [ + 'teacher_id' => $teacherId, + 'class_id' => (int) $data['class_id'], + 'file_path' => '', + 'page_selection' => null, + 'num_copies' => (int) $data['num_copies'], + 'required_by' => $data['required_by'], + 'pickup_method' => (string) $data['pickup_method'], + 'status' => 'not_assigned', + ]; + + $model = PrintRequest::query()->create($row); + $this->notifyAdminsForPrintRequest((int) $model->id, array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]), 'created'); + + return $model->fresh(); + } + + /** + * Teacher may download own files; admins may download any. + */ + public function authorizeDownload(PrintRequest $request, int $userId, bool $userIsPrintAdmin): bool + { + if ($userIsPrintAdmin) { + return true; + } + + return $this->teacherOwnsRequest($request, $userId); + } + + public function absolutePathForDownload(PrintRequest $request): ?string + { + $fp = trim((string) ($request->file_path ?? '')); + if ($fp === '') { + return null; + } + + return $this->resolveExistingFilePath($fp); + } +} diff --git a/app/Services/PublicWinners/PublicWinnersPortalService.php b/app/Services/PublicWinners/PublicWinnersPortalService.php new file mode 100644 index 00000000..d623938c --- /dev/null +++ b/app/Services/PublicWinners/PublicWinnersPortalService.php @@ -0,0 +1,114 @@ +> + */ + public function publishedCompetitions(): array + { + return Competition::query() + ->where('is_published', 1) + ->orderByDesc('published_at') + ->get() + ->map(fn (Competition $c) => $this->normalizeCompetition($c)) + ->values() + ->all(); + } + + /** + * @return array{ + * competition: array, + * winners_by_class: array>>, + * section_map: array, + * question_counts: array + * }|null + */ + public function competitionPayload(int $id): ?array + { + $competition = Competition::query() + ->where('is_published', 1) + ->find($id); + + if (! $competition) { + return null; + } + + $winnerRows = DB::table('competition_winners as cw') + ->select('cw.*', 's.firstname', 's.lastname', 'cs.class_section_name') + ->leftJoin('students as s', 's.id', '=', 'cw.student_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'cw.class_section_id') + ->where('cw.competition_id', $id) + ->orderBy('cw.class_section_id', 'ASC') + ->orderBy('cw.rank', 'ASC') + ->get(); + + $sectionMap = []; + foreach ( + ClassSection::query() + ->select(['class_section_id', 'class_section_name']) + ->get() as $section + ) { + $sid = (int) ($section->class_section_id ?? 0); + if ($sid > 0) { + $sectionMap[$sid] = (string) ($section->class_section_name ?? (string) $sid); + } + } + + $winnersByClass = []; + foreach ($winnerRows as $row) { + $arr = (array) $row; + $classId = (int) ($arr['class_section_id'] ?? 0); + $fname = (string) ($arr['firstname'] ?? ''); + $lname = (string) ($arr['lastname'] ?? ''); + $name = trim($fname.' '.$lname); + $arr['name'] = $name !== '' ? $name : ('Student #'.($arr['student_id'] ?? '')); + $winnersByClass[$classId][] = $arr; + } + + $questionCounts = []; + $qRows = DB::table('competition_class_winners') + ->select(['class_section_id', 'question_count']) + ->where('competition_id', $id) + ->get(); + + foreach ($qRows as $row) { + $arr = (array) $row; + $classId = (int) ($arr['class_section_id'] ?? 0); + if ($classId > 0 && isset($arr['question_count']) && $arr['question_count'] !== null) { + $questionCounts[$classId] = (int) $arr['question_count']; + } + } + + return [ + 'competition' => $this->normalizeCompetition($competition), + 'winners_by_class' => $winnersByClass, + 'section_map' => $sectionMap, + 'question_counts' => $questionCounts, + ]; + } + + /** + * @return array + */ + private function normalizeCompetition(Competition $c): array + { + $arr = $c->toArray(); + foreach ($arr as $key => $value) { + if ($value instanceof \Carbon\CarbonInterface) { + $arr[$key] = $value->format('Y-m-d H:i:s'); + } + } + + return $arr; + } +} diff --git a/app/Services/Refunds/RefundOverpaymentService.php b/app/Services/Refunds/RefundOverpaymentService.php index 52758867..5b875617 100644 --- a/app/Services/Refunds/RefundOverpaymentService.php +++ b/app/Services/Refunds/RefundOverpaymentService.php @@ -5,14 +5,17 @@ namespace App\Services\Refunds; use App\Models\Configuration; use App\Models\Invoice; use App\Models\Refund; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; class RefundOverpaymentService { - public function __construct(private RefundNotificationService $notifications) - { + public function __construct( + private RefundNotificationService $notifications, + private ApplicationUrlService $urls, + ) { } public function recalculate(bool $triggerEvents = false, ?string $invoiceNumber = null, ?int $actorId = null): array @@ -146,7 +149,7 @@ class RefundOverpaymentService ]); if ($refund && $triggerEvents) { - $this->notifications->notifyPending($pid, $over, url('/login')); + $this->notifications->notifyPending($pid, $over, $this->urls->webLoginUrl()); } return ['ok' => true, 'message' => 'Overpayment detected and refund created.']; @@ -250,7 +253,7 @@ class RefundOverpaymentService if ($refund) { $created[] = (int) $refund->id; if ($triggerEvents) { - $this->notifications->notifyPending($pid, $over, url('/login')); + $this->notifications->notifyPending($pid, $over, $this->urls->webLoginUrl()); } } } elseif (in_array($existing->status, [Refund::STATUS_PENDING, Refund::STATUS_PARTIAL], true)) { diff --git a/app/Services/Refunds/RefundRequestService.php b/app/Services/Refunds/RefundRequestService.php index e2236118..fbc37bb9 100644 --- a/app/Services/Refunds/RefundRequestService.php +++ b/app/Services/Refunds/RefundRequestService.php @@ -5,6 +5,7 @@ namespace App\Services\Refunds; use App\Models\Configuration; use App\Models\Refund; use App\Models\User; +use App\Services\ApplicationUrlService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -15,7 +16,8 @@ class RefundRequestService public function __construct( private RefundPolicyService $policyService, private RefundSummaryService $summaryService, - private RefundNotificationService $notifications + private RefundNotificationService $notifications, + private ApplicationUrlService $urls, ) { } @@ -49,7 +51,7 @@ class RefundRequestService } if (($result['refund_amount'] ?? 0) > 0) { - $this->notifications->notifyPending($parentId, (float) $result['refund_amount'], url('/login')); + $this->notifications->notifyPending($parentId, (float) $result['refund_amount'], $this->urls->webLoginUrl()); } return [ @@ -114,7 +116,7 @@ class RefundRequestService return ['ok' => false, 'message' => 'Failed to create refund request.']; } - $this->notifications->notifyPending($parentId, $amount, url('/login')); + $this->notifications->notifyPending($parentId, $amount, $this->urls->webLoginUrl()); return [ 'ok' => true, diff --git a/app/Services/Reimbursements/ReimbursementFileService.php b/app/Services/Reimbursements/ReimbursementFileService.php index a99d60a7..a842e351 100644 --- a/app/Services/Reimbursements/ReimbursementFileService.php +++ b/app/Services/Reimbursements/ReimbursementFileService.php @@ -3,33 +3,31 @@ namespace App\Services\Reimbursements; use App\Models\ReimbursementBatchAdminFile; +use App\Services\ApplicationUrlService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Str; use RuntimeException; class ReimbursementFileService { + public function __construct( + private ApplicationUrlService $urls + ) { + } + public function receiptUrl(?string $filename): ?string { - if (!$filename) { - return null; - } - $safe = basename(trim($filename)); - return $safe !== '' ? url('/api/v1/files/reimbursements/' . $safe) : null; + return $this->urls->forReimbursementStorage($filename); } public function expenseReceiptUrl(?string $filename): ?string { - if (!$filename) { - return null; - } - $safe = basename(trim($filename)); - return $safe !== '' ? url('/api/v1/files/receipts/' . $safe) : null; + return $this->urls->forReceiptStorage($filename); } public function adminFileUrl(string $filename, string $mode = 'inline'): string { - return url('/api/v1/finance/reimbursements/batches/admin-files/' . rawurlencode($filename) . '/' . $mode); + return $this->urls->forReimbursementAdminCheckFile($filename, $mode); } public function storeReceipt(?UploadedFile $file): ?string diff --git a/app/Services/Reports/SlipPrinterPdfService.php b/app/Services/Reports/SlipPrinterPdfService.php index 2331131c..e2bdd688 100644 --- a/app/Services/Reports/SlipPrinterPdfService.php +++ b/app/Services/Reports/SlipPrinterPdfService.php @@ -2,6 +2,7 @@ namespace App\Services\Reports; +use App\Support\MailHtml; use Dompdf\Dompdf; use Dompdf\Options; @@ -57,10 +58,9 @@ class SlipPrinterPdfService $hMm = $dynHeightMm; } - $html = view('slips/slip_pdf', [ - 'entry' => $data, - 'lines' => $lines, - 'page' => ['w_mm' => $wMm, 'h_mm' => $hMm], + $html = MailHtml::slipPdfHtml($data, $lines, [ + 'w_mm' => $wMm, + 'h_mm' => $hMm, ]); $optionsObj = new Options(); diff --git a/app/Services/School/AccountEventService.php b/app/Services/School/AccountEventService.php index e7386c50..36f4f24e 100644 --- a/app/Services/School/AccountEventService.php +++ b/app/Services/School/AccountEventService.php @@ -315,10 +315,8 @@ class AccountEventService private function renderEmail(string $viewName, array $data, string $fallbackTitle): string { - try { - return view($viewName, $data, ['saveData' => true]); - } catch (\Throwable $e) { - return '

    ' . e($fallbackTitle) . '

    '; - } + Log::debug('Skipping Blade email template', ['view' => $viewName]); + + return '

    ' . e($fallbackTitle) . '

    '; } } diff --git a/app/Services/School/EnrollmentEventService.php b/app/Services/School/EnrollmentEventService.php index b9fea62d..b61719cb 100644 --- a/app/Services/School/EnrollmentEventService.php +++ b/app/Services/School/EnrollmentEventService.php @@ -2,6 +2,7 @@ namespace App\Services\School; +use App\Services\ApplicationUrlService; use App\Services\EmailService; use App\Services\Notifications\UserNotificationDispatchService; use Illuminate\Support\Facades\Log; @@ -10,7 +11,8 @@ class EnrollmentEventService { public function __construct( private EmailService $emailService, - private UserNotificationDispatchService $notifier + private UserNotificationDispatchService $notifier, + private ApplicationUrlService $urls, ) { } @@ -22,7 +24,7 @@ class EnrollmentEventService $emailData = [ 'parentName' => $parentName, 'studentName' => $studentName, - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Admission Application Under Review', ]; @@ -51,7 +53,7 @@ class EnrollmentEventService 'studentName' => $studentName, 'amountDue' => $parentData['amount'] ?? '', 'dueDate' => $parentData['due_date'] ?? '', - 'portalLink' => $parentData['paymentLink'] ?? ($parentData['portalLink'] ?? url('/login')), + 'portalLink' => $parentData['paymentLink'] ?? ($parentData['portalLink'] ?? $this->urls->webLoginUrl()), 'title' => 'Admission Decision', ]; @@ -86,7 +88,7 @@ class EnrollmentEventService 'teacherName' => $parentData['teacher_name'] ?? ($first['teacherName'] ?? ''), 'startDate' => $parentData['start_date'] ?? ($first['startDate'] ?? ''), 'students' => count($studentsDetails) > 1 ? $studentsDetails : null, - 'portalLink' => url('/login'), + 'portalLink' => $this->urls->webLoginUrl(), 'title' => 'Enrollment Confirmation', ]; @@ -120,7 +122,7 @@ class EnrollmentEventService $emailData = [ 'parentName' => $parentName, 'studentName' => $parentData['student_name'] ?? $studentName, - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Withdrawal Under Review', ]; @@ -148,7 +150,7 @@ class EnrollmentEventService 'parentName' => $parentName, 'studentName' => $studentName, 'refundAmount' => $parentData['amount'] ?? '', - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Refund Pending', ]; @@ -178,7 +180,7 @@ class EnrollmentEventService 'studentName' => $studentName, 'withdrawalDate' => $parentData['withdrawal_date'] ?? ($parentData['date'] ?? ''), 'withdrawals' => !empty($withdrawals) ? $withdrawals : null, - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Withdrawal Confirmed', ]; @@ -207,7 +209,7 @@ class EnrollmentEventService 'parentName' => $parentName, 'studentName' => $studentName, 'students' => $studentsWithReasons ?: null, - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Admission Decision', ]; @@ -234,7 +236,7 @@ class EnrollmentEventService $emailData = [ 'parentName' => $parentName, 'studentName' => $studentName, - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Waitlist Update', ]; @@ -254,7 +256,7 @@ class EnrollmentEventService 'parentName' => $parentName, 'schoolYear' => $parentData['school_year'] ?? '', 'changes' => $parentData['changes'] ?? [], - 'portalLink' => $parentData['portalLink'] ?? url('/login'), + 'portalLink' => $parentData['portalLink'] ?? $this->urls->webLoginUrl(), 'title' => 'Enrollment Status Update', ]; @@ -409,10 +411,8 @@ class EnrollmentEventService private function renderEmail(string $viewName, array $data, string $fallbackTitle): string { - try { - return view($viewName, $data, ['saveData' => true]); - } catch (\Throwable $e) { - return '

    ' . e($fallbackTitle) . '

    '; - } + Log::debug('Skipping Blade email template', ['view' => $viewName]); + + return '

    ' . e($fallbackTitle) . '

    '; } } diff --git a/app/Services/School/PaymentEventService.php b/app/Services/School/PaymentEventService.php index 9c461303..23e84fd4 100644 --- a/app/Services/School/PaymentEventService.php +++ b/app/Services/School/PaymentEventService.php @@ -2,6 +2,7 @@ namespace App\Services\School; +use App\Services\ApplicationUrlService; use App\Services\EmailService; use App\Services\Notifications\UserNotificationDispatchService; use Illuminate\Support\Facades\Log; @@ -10,7 +11,8 @@ class PaymentEventService { public function __construct( private EmailService $emailService, - private UserNotificationDispatchService $notifier + private UserNotificationDispatchService $notifier, + private ApplicationUrlService $urls, ) { } @@ -65,7 +67,7 @@ class PaymentEventService 'installmentSeq' => (int) ($data['installment_seq'] ?? 1), 'invoiceDiscount' => $invoiceDiscountRaw > 0 ? $fmtMoney($invoiceDiscountRaw) : null, 'parentYearDiscountTotal' => $parentYearDiscountTotalRaw > 0 ? $fmtMoney($parentYearDiscountTotalRaw) : null, - 'portalLink' => url('/login'), + 'portalLink' => $this->urls->webLoginUrl(), 'students' => $studentdata, ]; @@ -165,7 +167,7 @@ class PaymentEventService 'postBalance' => $postBalance, 'createdAt' => $createdAt, 'dueDate' => $dueDate !== '' ? $dueDate : null, - 'portalLink' => $data['portal_link'] ?? url('/login'), + 'portalLink' => $data['portal_link'] ?? $this->urls->webLoginUrl(), 'invoiceLink' => $data['invoice_link'] ?? null, 'students' => $studentdata, ]; @@ -214,10 +216,8 @@ class PaymentEventService private function renderEmail(string $viewName, array $data, string $fallbackTitle): string { - try { - return view($viewName, $data, ['saveData' => true]); - } catch (\Throwable $e) { - return '

    ' . e($fallbackTitle) . '

    '; - } + Log::debug('Skipping Blade email template', ['view' => $viewName]); + + return '

    ' . e($fallbackTitle) . '

    '; } } diff --git a/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php b/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php index b0b49d17..d51eba72 100644 --- a/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php +++ b/app/Services/Settings/SchoolCalendar/SchoolCalendarNotificationService.php @@ -3,15 +3,17 @@ namespace App\Services\Settings\SchoolCalendar; use App\Models\User; +use App\Services\ApplicationUrlService; use App\Services\EmailService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\URL; class SchoolCalendarNotificationService { - public function __construct(private EmailService $emailService) - { + public function __construct( + private EmailService $emailService, + private ApplicationUrlService $urls, + ) { } public function notify(array $event, array $targets): array @@ -56,7 +58,7 @@ class SchoolCalendarNotificationService private function buildBody(array $event): string { - $calendarUrl = URL::to('/administrator/calendar_view'); + $calendarUrl = $this->urls->spaAdministratorCalendarViewUrl(); $title = trim((string) ($event['title'] ?? 'School Calendar Update')); $date = (string) ($event['date'] ?? ''); $description = trim((string) ($event['description'] ?? '')); diff --git a/app/Services/Teachers/TeacherAbsenceService.php b/app/Services/Teachers/TeacherAbsenceService.php index 438612ef..43817f7c 100644 --- a/app/Services/Teachers/TeacherAbsenceService.php +++ b/app/Services/Teachers/TeacherAbsenceService.php @@ -2,6 +2,7 @@ namespace App\Services\Teachers; +use App\Services\ApplicationUrlService; use App\Services\Staff\StaffTimeOffLinkService; use App\Models\StaffAttendance; use App\Models\TeacherClass; @@ -15,7 +16,8 @@ class TeacherAbsenceService public function __construct( private TeacherConfigService $configService, private SemesterRangeService $semesterRangeService, - private StaffTimeOffLinkService $staffTimeOffLinkService + private StaffTimeOffLinkService $staffTimeOffLinkService, + private ApplicationUrlService $urls, ) { } @@ -253,7 +255,7 @@ class TeacherAbsenceService 'origin' => 'teacher portal', ]); - $notifyUrl = url('/timeoff/notify/' . rawurlencode($token)); + $notifyUrl = $this->urls->timeoffNotifyUrl($token); $body .= '

    Click Send confirmation email to ' . e($fullName) . ' so the staff member is notified automatically. This link expires in 14 days.

    '; diff --git a/app/Services/Whatsapp/WhatsappInviteEmailService.php b/app/Services/Whatsapp/WhatsappInviteEmailService.php index b1039e5a..b70f42d8 100644 --- a/app/Services/Whatsapp/WhatsappInviteEmailService.php +++ b/app/Services/Whatsapp/WhatsappInviteEmailService.php @@ -30,8 +30,6 @@ class WhatsappInviteEmailService $sections = (array) ($payload['sections'] ?? []); $links = array_values(array_unique(array_filter((array) ($payload['links'] ?? [])))); $parent = $payload['parent'] ?? null; - $schoolYear = $payload['schoolYear'] ?? null; - $semester = $payload['semester'] ?? null; $items = $this->buildItems($sections, $links); if (empty($items)) { @@ -43,29 +41,13 @@ class WhatsappInviteEmailService ? 'Your WhatsApp Group Links (Multiple Classes)' : 'Your WhatsApp Group Link'; - $viewData = [ - 'parent' => $parent, - 'items' => $items, - 'sections' => $sections, - 'links' => $links, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - 'title' => $subject, - 'teacherNames' => $payload['teachers'] ?? [], - ]; - - $bodyHtml = ''; - try { - $bodyHtml = view('emails/whatsapp_invite', $viewData, ['saveData' => true]); - } catch (\Throwable $e) { - $lines = ['Assalamu Alaikum,', '', 'Here are your WhatsApp link(s):']; - foreach ($items as $it) { - $lines[] = ' - ' . $it['class_section_name'] . ': ' . $it['invite_link']; - } - $lines[] = ''; - $lines[] = 'Jazakumu-llahu khayran.'; - $bodyHtml = nl2br(implode("\n", $lines)); + $lines = ['Assalamu Alaikum,', '', 'Here are your WhatsApp link(s):']; + foreach ($items as $it) { + $lines[] = ' - '.$it['class_section_name'].': '.$it['invite_link']; } + $lines[] = ''; + $lines[] = 'Jazakumu-llahu khayran.'; + $bodyHtml = nl2br(implode("\n", $lines)); $ok = $this->emailService->send($to, $subject, $bodyHtml, 'notifications', $cc); diff --git a/app/Support/MailHtml.php b/app/Support/MailHtml.php new file mode 100644 index 00000000..df06e07f --- /dev/null +++ b/app/Support/MailHtml.php @@ -0,0 +1,89 @@ +' + . '' + . ''.$t.'' + . '' + . '
    ' + .$bodyInnerHtml + . '
    '; + } + + /** Late slip PDF body (Dompdf). */ + public static function slipPdfHtml(array $entry, array $lines, array $page): string + { + $name = htmlspecialchars((string) ($entry['student_name'] ?? ''), ENT_QUOTES, 'UTF-8'); + $blocks = []; + foreach ($lines as $line) { + $blocks[] = '
    ' + .htmlspecialchars((string) $line, ENT_QUOTES, 'UTF-8').'
    '; + } + + return '' + .'' + .'
    '.$name.'
    ' + .implode('', $blocks) + .''; + } + + /** Below-60 / performance alert email body (wrapped in {@see document()} by caller). */ + public static function belowSixtyInnerHtml(array $d): string + { + $scores = $d['scores'] ?? []; + $rows = ''; + if (is_array($scores)) { + foreach ($scores as $row) { + if (! is_array($row)) { + continue; + } + $label = htmlspecialchars((string) ($row['label'] ?? $row['name'] ?? ''), ENT_QUOTES, 'UTF-8'); + $val = htmlspecialchars((string) ($row['value'] ?? $row['score'] ?? ''), ENT_QUOTES, 'UTF-8'); + $rows .= ''.$label.'' + .''.$val.''; + } + } + $comment = htmlspecialchars((string) ($d['comment'] ?? ''), ENT_QUOTES, 'UTF-8'); + $title = htmlspecialchars((string) ($d['title'] ?? 'Performance alert'), ENT_QUOTES, 'UTF-8'); + + return '

    '.$title.'

    ' + .'

    Dear '.htmlspecialchars((string) ($d['parent_name'] ?? 'Parent/Guardian'), ENT_QUOTES, 'UTF-8').',

    ' + .'

    Student: '.htmlspecialchars((string) ($d['student_name'] ?? ''), ENT_QUOTES, 'UTF-8').'

    ' + .'

    Class: '.htmlspecialchars((string) ($d['class_section_name'] ?? ''), ENT_QUOTES, 'UTF-8').'

    ' + .(($d['semester'] ?? '') !== '' || ($d['school_year'] ?? '') !== '' + ? '

    '.htmlspecialchars(trim((string) ($d['semester'] ?? '').' '.(string) ($d['school_year'] ?? '')), ENT_QUOTES, 'UTF-8').'

    ' + : '') + .($rows !== '' ? ''.$rows.'
    ' : '') + .($comment !== '' ? '

    Comment: '.$comment.'

    ' : ''); + } + + /** Broadcast email outer shell (replaces emails.broadcast_wrapper). */ + public static function broadcastCompose( + string $subject, + string $contentHtml, + string $preheader, + string $ctaText, + string $ctaUrl + ): string { + $pre = $preheader !== '' + ? '

    '.htmlspecialchars($preheader, ENT_QUOTES, 'UTF-8').'

    ' + : ''; + $cta = ($ctaText !== '' && $ctaUrl !== '') + ? '

    ' + .htmlspecialchars($ctaText, ENT_QUOTES, 'UTF-8').'

    ' + : ''; + + return self::document($subject, $pre.'
    '.$contentHtml.'
    '.$cta); + } +} diff --git a/app/ThirdParty/fpdf/font/courier.php b/app/ThirdParty/fpdf/font/courier.php new file mode 100644 index 00000000..bc8478eb --- /dev/null +++ b/app/ThirdParty/fpdf/font/courier.php @@ -0,0 +1,10 @@ +array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/courierb.php b/app/ThirdParty/fpdf/font/courierb.php new file mode 100644 index 00000000..97ecd701 --- /dev/null +++ b/app/ThirdParty/fpdf/font/courierb.php @@ -0,0 +1,10 @@ +array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/courierbi.php b/app/ThirdParty/fpdf/font/courierbi.php new file mode 100644 index 00000000..c4bfff88 --- /dev/null +++ b/app/ThirdParty/fpdf/font/courierbi.php @@ -0,0 +1,10 @@ +array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/courieri.php b/app/ThirdParty/fpdf/font/courieri.php new file mode 100644 index 00000000..015a15ad --- /dev/null +++ b/app/ThirdParty/fpdf/font/courieri.php @@ -0,0 +1,10 @@ +array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/helvetica.php b/app/ThirdParty/fpdf/font/helvetica.php new file mode 100644 index 00000000..927759b9 --- /dev/null +++ b/app/ThirdParty/fpdf/font/helvetica.php @@ -0,0 +1,21 @@ +278,chr(1)=>278,chr(2)=>278,chr(3)=>278,chr(4)=>278,chr(5)=>278,chr(6)=>278,chr(7)=>278,chr(8)=>278,chr(9)=>278,chr(10)=>278,chr(11)=>278,chr(12)=>278,chr(13)=>278,chr(14)=>278,chr(15)=>278,chr(16)=>278,chr(17)=>278,chr(18)=>278,chr(19)=>278,chr(20)=>278,chr(21)=>278, + chr(22)=>278,chr(23)=>278,chr(24)=>278,chr(25)=>278,chr(26)=>278,chr(27)=>278,chr(28)=>278,chr(29)=>278,chr(30)=>278,chr(31)=>278,' '=>278,'!'=>278,'"'=>355,'#'=>556,'$'=>556,'%'=>889,'&'=>667,'\''=>191,'('=>333,')'=>333,'*'=>389,'+'=>584, + ','=>278,'-'=>333,'.'=>278,'/'=>278,'0'=>556,'1'=>556,'2'=>556,'3'=>556,'4'=>556,'5'=>556,'6'=>556,'7'=>556,'8'=>556,'9'=>556,':'=>278,';'=>278,'<'=>584,'='=>584,'>'=>584,'?'=>556,'@'=>1015,'A'=>667, + 'B'=>667,'C'=>722,'D'=>722,'E'=>667,'F'=>611,'G'=>778,'H'=>722,'I'=>278,'J'=>500,'K'=>667,'L'=>556,'M'=>833,'N'=>722,'O'=>778,'P'=>667,'Q'=>778,'R'=>722,'S'=>667,'T'=>611,'U'=>722,'V'=>667,'W'=>944, + 'X'=>667,'Y'=>667,'Z'=>611,'['=>278,'\\'=>278,']'=>278,'^'=>469,'_'=>556,'`'=>333,'a'=>556,'b'=>556,'c'=>500,'d'=>556,'e'=>556,'f'=>278,'g'=>556,'h'=>556,'i'=>222,'j'=>222,'k'=>500,'l'=>222,'m'=>833, + 'n'=>556,'o'=>556,'p'=>556,'q'=>556,'r'=>333,'s'=>500,'t'=>278,'u'=>556,'v'=>500,'w'=>722,'x'=>500,'y'=>500,'z'=>500,'{'=>334,'|'=>260,'}'=>334,'~'=>584,chr(127)=>350,chr(128)=>556,chr(129)=>350,chr(130)=>222,chr(131)=>556, + chr(132)=>333,chr(133)=>1000,chr(134)=>556,chr(135)=>556,chr(136)=>333,chr(137)=>1000,chr(138)=>667,chr(139)=>333,chr(140)=>1000,chr(141)=>350,chr(142)=>611,chr(143)=>350,chr(144)=>350,chr(145)=>222,chr(146)=>222,chr(147)=>333,chr(148)=>333,chr(149)=>350,chr(150)=>556,chr(151)=>1000,chr(152)=>333,chr(153)=>1000, + chr(154)=>500,chr(155)=>333,chr(156)=>944,chr(157)=>350,chr(158)=>500,chr(159)=>667,chr(160)=>278,chr(161)=>333,chr(162)=>556,chr(163)=>556,chr(164)=>556,chr(165)=>556,chr(166)=>260,chr(167)=>556,chr(168)=>333,chr(169)=>737,chr(170)=>370,chr(171)=>556,chr(172)=>584,chr(173)=>333,chr(174)=>737,chr(175)=>333, + chr(176)=>400,chr(177)=>584,chr(178)=>333,chr(179)=>333,chr(180)=>333,chr(181)=>556,chr(182)=>537,chr(183)=>278,chr(184)=>333,chr(185)=>333,chr(186)=>365,chr(187)=>556,chr(188)=>834,chr(189)=>834,chr(190)=>834,chr(191)=>611,chr(192)=>667,chr(193)=>667,chr(194)=>667,chr(195)=>667,chr(196)=>667,chr(197)=>667, + chr(198)=>1000,chr(199)=>722,chr(200)=>667,chr(201)=>667,chr(202)=>667,chr(203)=>667,chr(204)=>278,chr(205)=>278,chr(206)=>278,chr(207)=>278,chr(208)=>722,chr(209)=>722,chr(210)=>778,chr(211)=>778,chr(212)=>778,chr(213)=>778,chr(214)=>778,chr(215)=>584,chr(216)=>778,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>667,chr(222)=>667,chr(223)=>611,chr(224)=>556,chr(225)=>556,chr(226)=>556,chr(227)=>556,chr(228)=>556,chr(229)=>556,chr(230)=>889,chr(231)=>500,chr(232)=>556,chr(233)=>556,chr(234)=>556,chr(235)=>556,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>556,chr(241)=>556, + chr(242)=>556,chr(243)=>556,chr(244)=>556,chr(245)=>556,chr(246)=>556,chr(247)=>584,chr(248)=>611,chr(249)=>556,chr(250)=>556,chr(251)=>556,chr(252)=>556,chr(253)=>500,chr(254)=>556,chr(255)=>500); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/helveticab.php b/app/ThirdParty/fpdf/font/helveticab.php new file mode 100644 index 00000000..bcd73672 --- /dev/null +++ b/app/ThirdParty/fpdf/font/helveticab.php @@ -0,0 +1,21 @@ +278,chr(1)=>278,chr(2)=>278,chr(3)=>278,chr(4)=>278,chr(5)=>278,chr(6)=>278,chr(7)=>278,chr(8)=>278,chr(9)=>278,chr(10)=>278,chr(11)=>278,chr(12)=>278,chr(13)=>278,chr(14)=>278,chr(15)=>278,chr(16)=>278,chr(17)=>278,chr(18)=>278,chr(19)=>278,chr(20)=>278,chr(21)=>278, + chr(22)=>278,chr(23)=>278,chr(24)=>278,chr(25)=>278,chr(26)=>278,chr(27)=>278,chr(28)=>278,chr(29)=>278,chr(30)=>278,chr(31)=>278,' '=>278,'!'=>333,'"'=>474,'#'=>556,'$'=>556,'%'=>889,'&'=>722,'\''=>238,'('=>333,')'=>333,'*'=>389,'+'=>584, + ','=>278,'-'=>333,'.'=>278,'/'=>278,'0'=>556,'1'=>556,'2'=>556,'3'=>556,'4'=>556,'5'=>556,'6'=>556,'7'=>556,'8'=>556,'9'=>556,':'=>333,';'=>333,'<'=>584,'='=>584,'>'=>584,'?'=>611,'@'=>975,'A'=>722, + 'B'=>722,'C'=>722,'D'=>722,'E'=>667,'F'=>611,'G'=>778,'H'=>722,'I'=>278,'J'=>556,'K'=>722,'L'=>611,'M'=>833,'N'=>722,'O'=>778,'P'=>667,'Q'=>778,'R'=>722,'S'=>667,'T'=>611,'U'=>722,'V'=>667,'W'=>944, + 'X'=>667,'Y'=>667,'Z'=>611,'['=>333,'\\'=>278,']'=>333,'^'=>584,'_'=>556,'`'=>333,'a'=>556,'b'=>611,'c'=>556,'d'=>611,'e'=>556,'f'=>333,'g'=>611,'h'=>611,'i'=>278,'j'=>278,'k'=>556,'l'=>278,'m'=>889, + 'n'=>611,'o'=>611,'p'=>611,'q'=>611,'r'=>389,'s'=>556,'t'=>333,'u'=>611,'v'=>556,'w'=>778,'x'=>556,'y'=>556,'z'=>500,'{'=>389,'|'=>280,'}'=>389,'~'=>584,chr(127)=>350,chr(128)=>556,chr(129)=>350,chr(130)=>278,chr(131)=>556, + chr(132)=>500,chr(133)=>1000,chr(134)=>556,chr(135)=>556,chr(136)=>333,chr(137)=>1000,chr(138)=>667,chr(139)=>333,chr(140)=>1000,chr(141)=>350,chr(142)=>611,chr(143)=>350,chr(144)=>350,chr(145)=>278,chr(146)=>278,chr(147)=>500,chr(148)=>500,chr(149)=>350,chr(150)=>556,chr(151)=>1000,chr(152)=>333,chr(153)=>1000, + chr(154)=>556,chr(155)=>333,chr(156)=>944,chr(157)=>350,chr(158)=>500,chr(159)=>667,chr(160)=>278,chr(161)=>333,chr(162)=>556,chr(163)=>556,chr(164)=>556,chr(165)=>556,chr(166)=>280,chr(167)=>556,chr(168)=>333,chr(169)=>737,chr(170)=>370,chr(171)=>556,chr(172)=>584,chr(173)=>333,chr(174)=>737,chr(175)=>333, + chr(176)=>400,chr(177)=>584,chr(178)=>333,chr(179)=>333,chr(180)=>333,chr(181)=>611,chr(182)=>556,chr(183)=>278,chr(184)=>333,chr(185)=>333,chr(186)=>365,chr(187)=>556,chr(188)=>834,chr(189)=>834,chr(190)=>834,chr(191)=>611,chr(192)=>722,chr(193)=>722,chr(194)=>722,chr(195)=>722,chr(196)=>722,chr(197)=>722, + chr(198)=>1000,chr(199)=>722,chr(200)=>667,chr(201)=>667,chr(202)=>667,chr(203)=>667,chr(204)=>278,chr(205)=>278,chr(206)=>278,chr(207)=>278,chr(208)=>722,chr(209)=>722,chr(210)=>778,chr(211)=>778,chr(212)=>778,chr(213)=>778,chr(214)=>778,chr(215)=>584,chr(216)=>778,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>667,chr(222)=>667,chr(223)=>611,chr(224)=>556,chr(225)=>556,chr(226)=>556,chr(227)=>556,chr(228)=>556,chr(229)=>556,chr(230)=>889,chr(231)=>556,chr(232)=>556,chr(233)=>556,chr(234)=>556,chr(235)=>556,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>611,chr(241)=>611, + chr(242)=>611,chr(243)=>611,chr(244)=>611,chr(245)=>611,chr(246)=>611,chr(247)=>584,chr(248)=>611,chr(249)=>611,chr(250)=>611,chr(251)=>611,chr(252)=>611,chr(253)=>556,chr(254)=>611,chr(255)=>556); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/helveticabi.php b/app/ThirdParty/fpdf/font/helveticabi.php new file mode 100644 index 00000000..0243cde4 --- /dev/null +++ b/app/ThirdParty/fpdf/font/helveticabi.php @@ -0,0 +1,21 @@ +278,chr(1)=>278,chr(2)=>278,chr(3)=>278,chr(4)=>278,chr(5)=>278,chr(6)=>278,chr(7)=>278,chr(8)=>278,chr(9)=>278,chr(10)=>278,chr(11)=>278,chr(12)=>278,chr(13)=>278,chr(14)=>278,chr(15)=>278,chr(16)=>278,chr(17)=>278,chr(18)=>278,chr(19)=>278,chr(20)=>278,chr(21)=>278, + chr(22)=>278,chr(23)=>278,chr(24)=>278,chr(25)=>278,chr(26)=>278,chr(27)=>278,chr(28)=>278,chr(29)=>278,chr(30)=>278,chr(31)=>278,' '=>278,'!'=>333,'"'=>474,'#'=>556,'$'=>556,'%'=>889,'&'=>722,'\''=>238,'('=>333,')'=>333,'*'=>389,'+'=>584, + ','=>278,'-'=>333,'.'=>278,'/'=>278,'0'=>556,'1'=>556,'2'=>556,'3'=>556,'4'=>556,'5'=>556,'6'=>556,'7'=>556,'8'=>556,'9'=>556,':'=>333,';'=>333,'<'=>584,'='=>584,'>'=>584,'?'=>611,'@'=>975,'A'=>722, + 'B'=>722,'C'=>722,'D'=>722,'E'=>667,'F'=>611,'G'=>778,'H'=>722,'I'=>278,'J'=>556,'K'=>722,'L'=>611,'M'=>833,'N'=>722,'O'=>778,'P'=>667,'Q'=>778,'R'=>722,'S'=>667,'T'=>611,'U'=>722,'V'=>667,'W'=>944, + 'X'=>667,'Y'=>667,'Z'=>611,'['=>333,'\\'=>278,']'=>333,'^'=>584,'_'=>556,'`'=>333,'a'=>556,'b'=>611,'c'=>556,'d'=>611,'e'=>556,'f'=>333,'g'=>611,'h'=>611,'i'=>278,'j'=>278,'k'=>556,'l'=>278,'m'=>889, + 'n'=>611,'o'=>611,'p'=>611,'q'=>611,'r'=>389,'s'=>556,'t'=>333,'u'=>611,'v'=>556,'w'=>778,'x'=>556,'y'=>556,'z'=>500,'{'=>389,'|'=>280,'}'=>389,'~'=>584,chr(127)=>350,chr(128)=>556,chr(129)=>350,chr(130)=>278,chr(131)=>556, + chr(132)=>500,chr(133)=>1000,chr(134)=>556,chr(135)=>556,chr(136)=>333,chr(137)=>1000,chr(138)=>667,chr(139)=>333,chr(140)=>1000,chr(141)=>350,chr(142)=>611,chr(143)=>350,chr(144)=>350,chr(145)=>278,chr(146)=>278,chr(147)=>500,chr(148)=>500,chr(149)=>350,chr(150)=>556,chr(151)=>1000,chr(152)=>333,chr(153)=>1000, + chr(154)=>556,chr(155)=>333,chr(156)=>944,chr(157)=>350,chr(158)=>500,chr(159)=>667,chr(160)=>278,chr(161)=>333,chr(162)=>556,chr(163)=>556,chr(164)=>556,chr(165)=>556,chr(166)=>280,chr(167)=>556,chr(168)=>333,chr(169)=>737,chr(170)=>370,chr(171)=>556,chr(172)=>584,chr(173)=>333,chr(174)=>737,chr(175)=>333, + chr(176)=>400,chr(177)=>584,chr(178)=>333,chr(179)=>333,chr(180)=>333,chr(181)=>611,chr(182)=>556,chr(183)=>278,chr(184)=>333,chr(185)=>333,chr(186)=>365,chr(187)=>556,chr(188)=>834,chr(189)=>834,chr(190)=>834,chr(191)=>611,chr(192)=>722,chr(193)=>722,chr(194)=>722,chr(195)=>722,chr(196)=>722,chr(197)=>722, + chr(198)=>1000,chr(199)=>722,chr(200)=>667,chr(201)=>667,chr(202)=>667,chr(203)=>667,chr(204)=>278,chr(205)=>278,chr(206)=>278,chr(207)=>278,chr(208)=>722,chr(209)=>722,chr(210)=>778,chr(211)=>778,chr(212)=>778,chr(213)=>778,chr(214)=>778,chr(215)=>584,chr(216)=>778,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>667,chr(222)=>667,chr(223)=>611,chr(224)=>556,chr(225)=>556,chr(226)=>556,chr(227)=>556,chr(228)=>556,chr(229)=>556,chr(230)=>889,chr(231)=>556,chr(232)=>556,chr(233)=>556,chr(234)=>556,chr(235)=>556,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>611,chr(241)=>611, + chr(242)=>611,chr(243)=>611,chr(244)=>611,chr(245)=>611,chr(246)=>611,chr(247)=>584,chr(248)=>611,chr(249)=>611,chr(250)=>611,chr(251)=>611,chr(252)=>611,chr(253)=>556,chr(254)=>611,chr(255)=>556); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/helveticai.php b/app/ThirdParty/fpdf/font/helveticai.php new file mode 100644 index 00000000..06ec735b --- /dev/null +++ b/app/ThirdParty/fpdf/font/helveticai.php @@ -0,0 +1,21 @@ +278,chr(1)=>278,chr(2)=>278,chr(3)=>278,chr(4)=>278,chr(5)=>278,chr(6)=>278,chr(7)=>278,chr(8)=>278,chr(9)=>278,chr(10)=>278,chr(11)=>278,chr(12)=>278,chr(13)=>278,chr(14)=>278,chr(15)=>278,chr(16)=>278,chr(17)=>278,chr(18)=>278,chr(19)=>278,chr(20)=>278,chr(21)=>278, + chr(22)=>278,chr(23)=>278,chr(24)=>278,chr(25)=>278,chr(26)=>278,chr(27)=>278,chr(28)=>278,chr(29)=>278,chr(30)=>278,chr(31)=>278,' '=>278,'!'=>278,'"'=>355,'#'=>556,'$'=>556,'%'=>889,'&'=>667,'\''=>191,'('=>333,')'=>333,'*'=>389,'+'=>584, + ','=>278,'-'=>333,'.'=>278,'/'=>278,'0'=>556,'1'=>556,'2'=>556,'3'=>556,'4'=>556,'5'=>556,'6'=>556,'7'=>556,'8'=>556,'9'=>556,':'=>278,';'=>278,'<'=>584,'='=>584,'>'=>584,'?'=>556,'@'=>1015,'A'=>667, + 'B'=>667,'C'=>722,'D'=>722,'E'=>667,'F'=>611,'G'=>778,'H'=>722,'I'=>278,'J'=>500,'K'=>667,'L'=>556,'M'=>833,'N'=>722,'O'=>778,'P'=>667,'Q'=>778,'R'=>722,'S'=>667,'T'=>611,'U'=>722,'V'=>667,'W'=>944, + 'X'=>667,'Y'=>667,'Z'=>611,'['=>278,'\\'=>278,']'=>278,'^'=>469,'_'=>556,'`'=>333,'a'=>556,'b'=>556,'c'=>500,'d'=>556,'e'=>556,'f'=>278,'g'=>556,'h'=>556,'i'=>222,'j'=>222,'k'=>500,'l'=>222,'m'=>833, + 'n'=>556,'o'=>556,'p'=>556,'q'=>556,'r'=>333,'s'=>500,'t'=>278,'u'=>556,'v'=>500,'w'=>722,'x'=>500,'y'=>500,'z'=>500,'{'=>334,'|'=>260,'}'=>334,'~'=>584,chr(127)=>350,chr(128)=>556,chr(129)=>350,chr(130)=>222,chr(131)=>556, + chr(132)=>333,chr(133)=>1000,chr(134)=>556,chr(135)=>556,chr(136)=>333,chr(137)=>1000,chr(138)=>667,chr(139)=>333,chr(140)=>1000,chr(141)=>350,chr(142)=>611,chr(143)=>350,chr(144)=>350,chr(145)=>222,chr(146)=>222,chr(147)=>333,chr(148)=>333,chr(149)=>350,chr(150)=>556,chr(151)=>1000,chr(152)=>333,chr(153)=>1000, + chr(154)=>500,chr(155)=>333,chr(156)=>944,chr(157)=>350,chr(158)=>500,chr(159)=>667,chr(160)=>278,chr(161)=>333,chr(162)=>556,chr(163)=>556,chr(164)=>556,chr(165)=>556,chr(166)=>260,chr(167)=>556,chr(168)=>333,chr(169)=>737,chr(170)=>370,chr(171)=>556,chr(172)=>584,chr(173)=>333,chr(174)=>737,chr(175)=>333, + chr(176)=>400,chr(177)=>584,chr(178)=>333,chr(179)=>333,chr(180)=>333,chr(181)=>556,chr(182)=>537,chr(183)=>278,chr(184)=>333,chr(185)=>333,chr(186)=>365,chr(187)=>556,chr(188)=>834,chr(189)=>834,chr(190)=>834,chr(191)=>611,chr(192)=>667,chr(193)=>667,chr(194)=>667,chr(195)=>667,chr(196)=>667,chr(197)=>667, + chr(198)=>1000,chr(199)=>722,chr(200)=>667,chr(201)=>667,chr(202)=>667,chr(203)=>667,chr(204)=>278,chr(205)=>278,chr(206)=>278,chr(207)=>278,chr(208)=>722,chr(209)=>722,chr(210)=>778,chr(211)=>778,chr(212)=>778,chr(213)=>778,chr(214)=>778,chr(215)=>584,chr(216)=>778,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>667,chr(222)=>667,chr(223)=>611,chr(224)=>556,chr(225)=>556,chr(226)=>556,chr(227)=>556,chr(228)=>556,chr(229)=>556,chr(230)=>889,chr(231)=>500,chr(232)=>556,chr(233)=>556,chr(234)=>556,chr(235)=>556,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>556,chr(241)=>556, + chr(242)=>556,chr(243)=>556,chr(244)=>556,chr(245)=>556,chr(246)=>556,chr(247)=>584,chr(248)=>611,chr(249)=>556,chr(250)=>556,chr(251)=>556,chr(252)=>556,chr(253)=>500,chr(254)=>556,chr(255)=>500); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/symbol.php b/app/ThirdParty/fpdf/font/symbol.php new file mode 100644 index 00000000..f8f0c330 --- /dev/null +++ b/app/ThirdParty/fpdf/font/symbol.php @@ -0,0 +1,20 @@ +250,chr(1)=>250,chr(2)=>250,chr(3)=>250,chr(4)=>250,chr(5)=>250,chr(6)=>250,chr(7)=>250,chr(8)=>250,chr(9)=>250,chr(10)=>250,chr(11)=>250,chr(12)=>250,chr(13)=>250,chr(14)=>250,chr(15)=>250,chr(16)=>250,chr(17)=>250,chr(18)=>250,chr(19)=>250,chr(20)=>250,chr(21)=>250, + chr(22)=>250,chr(23)=>250,chr(24)=>250,chr(25)=>250,chr(26)=>250,chr(27)=>250,chr(28)=>250,chr(29)=>250,chr(30)=>250,chr(31)=>250,' '=>250,'!'=>333,'"'=>713,'#'=>500,'$'=>549,'%'=>833,'&'=>778,'\''=>439,'('=>333,')'=>333,'*'=>500,'+'=>549, + ','=>250,'-'=>549,'.'=>250,'/'=>278,'0'=>500,'1'=>500,'2'=>500,'3'=>500,'4'=>500,'5'=>500,'6'=>500,'7'=>500,'8'=>500,'9'=>500,':'=>278,';'=>278,'<'=>549,'='=>549,'>'=>549,'?'=>444,'@'=>549,'A'=>722, + 'B'=>667,'C'=>722,'D'=>612,'E'=>611,'F'=>763,'G'=>603,'H'=>722,'I'=>333,'J'=>631,'K'=>722,'L'=>686,'M'=>889,'N'=>722,'O'=>722,'P'=>768,'Q'=>741,'R'=>556,'S'=>592,'T'=>611,'U'=>690,'V'=>439,'W'=>768, + 'X'=>645,'Y'=>795,'Z'=>611,'['=>333,'\\'=>863,']'=>333,'^'=>658,'_'=>500,'`'=>500,'a'=>631,'b'=>549,'c'=>549,'d'=>494,'e'=>439,'f'=>521,'g'=>411,'h'=>603,'i'=>329,'j'=>603,'k'=>549,'l'=>549,'m'=>576, + 'n'=>521,'o'=>549,'p'=>549,'q'=>521,'r'=>549,'s'=>603,'t'=>439,'u'=>576,'v'=>713,'w'=>686,'x'=>493,'y'=>686,'z'=>494,'{'=>480,'|'=>200,'}'=>480,'~'=>549,chr(127)=>0,chr(128)=>0,chr(129)=>0,chr(130)=>0,chr(131)=>0, + chr(132)=>0,chr(133)=>0,chr(134)=>0,chr(135)=>0,chr(136)=>0,chr(137)=>0,chr(138)=>0,chr(139)=>0,chr(140)=>0,chr(141)=>0,chr(142)=>0,chr(143)=>0,chr(144)=>0,chr(145)=>0,chr(146)=>0,chr(147)=>0,chr(148)=>0,chr(149)=>0,chr(150)=>0,chr(151)=>0,chr(152)=>0,chr(153)=>0, + chr(154)=>0,chr(155)=>0,chr(156)=>0,chr(157)=>0,chr(158)=>0,chr(159)=>0,chr(160)=>750,chr(161)=>620,chr(162)=>247,chr(163)=>549,chr(164)=>167,chr(165)=>713,chr(166)=>500,chr(167)=>753,chr(168)=>753,chr(169)=>753,chr(170)=>753,chr(171)=>1042,chr(172)=>987,chr(173)=>603,chr(174)=>987,chr(175)=>603, + chr(176)=>400,chr(177)=>549,chr(178)=>411,chr(179)=>549,chr(180)=>549,chr(181)=>713,chr(182)=>494,chr(183)=>460,chr(184)=>549,chr(185)=>549,chr(186)=>549,chr(187)=>549,chr(188)=>1000,chr(189)=>603,chr(190)=>1000,chr(191)=>658,chr(192)=>823,chr(193)=>686,chr(194)=>795,chr(195)=>987,chr(196)=>768,chr(197)=>768, + chr(198)=>823,chr(199)=>768,chr(200)=>768,chr(201)=>713,chr(202)=>713,chr(203)=>713,chr(204)=>713,chr(205)=>713,chr(206)=>713,chr(207)=>713,chr(208)=>768,chr(209)=>713,chr(210)=>790,chr(211)=>790,chr(212)=>890,chr(213)=>823,chr(214)=>549,chr(215)=>250,chr(216)=>713,chr(217)=>603,chr(218)=>603,chr(219)=>1042, + chr(220)=>987,chr(221)=>603,chr(222)=>987,chr(223)=>603,chr(224)=>494,chr(225)=>329,chr(226)=>790,chr(227)=>790,chr(228)=>786,chr(229)=>713,chr(230)=>384,chr(231)=>384,chr(232)=>384,chr(233)=>384,chr(234)=>384,chr(235)=>384,chr(236)=>494,chr(237)=>494,chr(238)=>494,chr(239)=>494,chr(240)=>0,chr(241)=>329, + chr(242)=>274,chr(243)=>686,chr(244)=>686,chr(245)=>686,chr(246)=>384,chr(247)=>384,chr(248)=>384,chr(249)=>384,chr(250)=>384,chr(251)=>384,chr(252)=>494,chr(253)=>494,chr(254)=>494,chr(255)=>0); +$uv = array(32=>160,33=>33,34=>8704,35=>35,36=>8707,37=>array(37,2),39=>8715,40=>array(40,2),42=>8727,43=>array(43,2),45=>8722,46=>array(46,18),64=>8773,65=>array(913,2),67=>935,68=>array(916,2),70=>934,71=>915,72=>919,73=>921,74=>977,75=>array(922,4),79=>array(927,2),81=>920,82=>929,83=>array(931,3),86=>962,87=>937,88=>926,89=>936,90=>918,91=>91,92=>8756,93=>93,94=>8869,95=>95,96=>63717,97=>array(945,2),99=>967,100=>array(948,2),102=>966,103=>947,104=>951,105=>953,106=>981,107=>array(954,4),111=>array(959,2),113=>952,114=>961,115=>array(963,3),118=>982,119=>969,120=>958,121=>968,122=>950,123=>array(123,3),126=>8764,160=>8364,161=>978,162=>8242,163=>8804,164=>8725,165=>8734,166=>402,167=>9827,168=>9830,169=>9829,170=>9824,171=>8596,172=>array(8592,4),176=>array(176,2),178=>8243,179=>8805,180=>215,181=>8733,182=>8706,183=>8226,184=>247,185=>array(8800,2),187=>8776,188=>8230,189=>array(63718,2),191=>8629,192=>8501,193=>8465,194=>8476,195=>8472,196=>8855,197=>8853,198=>8709,199=>array(8745,2),201=>8835,202=>8839,203=>8836,204=>8834,205=>8838,206=>array(8712,2),208=>8736,209=>8711,210=>63194,211=>63193,212=>63195,213=>8719,214=>8730,215=>8901,216=>172,217=>array(8743,2),219=>8660,220=>array(8656,4),224=>9674,225=>9001,226=>array(63720,3),229=>8721,230=>array(63723,10),241=>9002,242=>8747,243=>8992,244=>63733,245=>8993,246=>array(63734,9)); +?> diff --git a/app/ThirdParty/fpdf/font/times.php b/app/ThirdParty/fpdf/font/times.php new file mode 100644 index 00000000..81f2a8b4 --- /dev/null +++ b/app/ThirdParty/fpdf/font/times.php @@ -0,0 +1,21 @@ +250,chr(1)=>250,chr(2)=>250,chr(3)=>250,chr(4)=>250,chr(5)=>250,chr(6)=>250,chr(7)=>250,chr(8)=>250,chr(9)=>250,chr(10)=>250,chr(11)=>250,chr(12)=>250,chr(13)=>250,chr(14)=>250,chr(15)=>250,chr(16)=>250,chr(17)=>250,chr(18)=>250,chr(19)=>250,chr(20)=>250,chr(21)=>250, + chr(22)=>250,chr(23)=>250,chr(24)=>250,chr(25)=>250,chr(26)=>250,chr(27)=>250,chr(28)=>250,chr(29)=>250,chr(30)=>250,chr(31)=>250,' '=>250,'!'=>333,'"'=>408,'#'=>500,'$'=>500,'%'=>833,'&'=>778,'\''=>180,'('=>333,')'=>333,'*'=>500,'+'=>564, + ','=>250,'-'=>333,'.'=>250,'/'=>278,'0'=>500,'1'=>500,'2'=>500,'3'=>500,'4'=>500,'5'=>500,'6'=>500,'7'=>500,'8'=>500,'9'=>500,':'=>278,';'=>278,'<'=>564,'='=>564,'>'=>564,'?'=>444,'@'=>921,'A'=>722, + 'B'=>667,'C'=>667,'D'=>722,'E'=>611,'F'=>556,'G'=>722,'H'=>722,'I'=>333,'J'=>389,'K'=>722,'L'=>611,'M'=>889,'N'=>722,'O'=>722,'P'=>556,'Q'=>722,'R'=>667,'S'=>556,'T'=>611,'U'=>722,'V'=>722,'W'=>944, + 'X'=>722,'Y'=>722,'Z'=>611,'['=>333,'\\'=>278,']'=>333,'^'=>469,'_'=>500,'`'=>333,'a'=>444,'b'=>500,'c'=>444,'d'=>500,'e'=>444,'f'=>333,'g'=>500,'h'=>500,'i'=>278,'j'=>278,'k'=>500,'l'=>278,'m'=>778, + 'n'=>500,'o'=>500,'p'=>500,'q'=>500,'r'=>333,'s'=>389,'t'=>278,'u'=>500,'v'=>500,'w'=>722,'x'=>500,'y'=>500,'z'=>444,'{'=>480,'|'=>200,'}'=>480,'~'=>541,chr(127)=>350,chr(128)=>500,chr(129)=>350,chr(130)=>333,chr(131)=>500, + chr(132)=>444,chr(133)=>1000,chr(134)=>500,chr(135)=>500,chr(136)=>333,chr(137)=>1000,chr(138)=>556,chr(139)=>333,chr(140)=>889,chr(141)=>350,chr(142)=>611,chr(143)=>350,chr(144)=>350,chr(145)=>333,chr(146)=>333,chr(147)=>444,chr(148)=>444,chr(149)=>350,chr(150)=>500,chr(151)=>1000,chr(152)=>333,chr(153)=>980, + chr(154)=>389,chr(155)=>333,chr(156)=>722,chr(157)=>350,chr(158)=>444,chr(159)=>722,chr(160)=>250,chr(161)=>333,chr(162)=>500,chr(163)=>500,chr(164)=>500,chr(165)=>500,chr(166)=>200,chr(167)=>500,chr(168)=>333,chr(169)=>760,chr(170)=>276,chr(171)=>500,chr(172)=>564,chr(173)=>333,chr(174)=>760,chr(175)=>333, + chr(176)=>400,chr(177)=>564,chr(178)=>300,chr(179)=>300,chr(180)=>333,chr(181)=>500,chr(182)=>453,chr(183)=>250,chr(184)=>333,chr(185)=>300,chr(186)=>310,chr(187)=>500,chr(188)=>750,chr(189)=>750,chr(190)=>750,chr(191)=>444,chr(192)=>722,chr(193)=>722,chr(194)=>722,chr(195)=>722,chr(196)=>722,chr(197)=>722, + chr(198)=>889,chr(199)=>667,chr(200)=>611,chr(201)=>611,chr(202)=>611,chr(203)=>611,chr(204)=>333,chr(205)=>333,chr(206)=>333,chr(207)=>333,chr(208)=>722,chr(209)=>722,chr(210)=>722,chr(211)=>722,chr(212)=>722,chr(213)=>722,chr(214)=>722,chr(215)=>564,chr(216)=>722,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>722,chr(222)=>556,chr(223)=>500,chr(224)=>444,chr(225)=>444,chr(226)=>444,chr(227)=>444,chr(228)=>444,chr(229)=>444,chr(230)=>667,chr(231)=>444,chr(232)=>444,chr(233)=>444,chr(234)=>444,chr(235)=>444,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>500,chr(241)=>500, + chr(242)=>500,chr(243)=>500,chr(244)=>500,chr(245)=>500,chr(246)=>500,chr(247)=>564,chr(248)=>500,chr(249)=>500,chr(250)=>500,chr(251)=>500,chr(252)=>500,chr(253)=>500,chr(254)=>500,chr(255)=>500); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/timesb.php b/app/ThirdParty/fpdf/font/timesb.php new file mode 100644 index 00000000..7db704fd --- /dev/null +++ b/app/ThirdParty/fpdf/font/timesb.php @@ -0,0 +1,21 @@ +250,chr(1)=>250,chr(2)=>250,chr(3)=>250,chr(4)=>250,chr(5)=>250,chr(6)=>250,chr(7)=>250,chr(8)=>250,chr(9)=>250,chr(10)=>250,chr(11)=>250,chr(12)=>250,chr(13)=>250,chr(14)=>250,chr(15)=>250,chr(16)=>250,chr(17)=>250,chr(18)=>250,chr(19)=>250,chr(20)=>250,chr(21)=>250, + chr(22)=>250,chr(23)=>250,chr(24)=>250,chr(25)=>250,chr(26)=>250,chr(27)=>250,chr(28)=>250,chr(29)=>250,chr(30)=>250,chr(31)=>250,' '=>250,'!'=>333,'"'=>555,'#'=>500,'$'=>500,'%'=>1000,'&'=>833,'\''=>278,'('=>333,')'=>333,'*'=>500,'+'=>570, + ','=>250,'-'=>333,'.'=>250,'/'=>278,'0'=>500,'1'=>500,'2'=>500,'3'=>500,'4'=>500,'5'=>500,'6'=>500,'7'=>500,'8'=>500,'9'=>500,':'=>333,';'=>333,'<'=>570,'='=>570,'>'=>570,'?'=>500,'@'=>930,'A'=>722, + 'B'=>667,'C'=>722,'D'=>722,'E'=>667,'F'=>611,'G'=>778,'H'=>778,'I'=>389,'J'=>500,'K'=>778,'L'=>667,'M'=>944,'N'=>722,'O'=>778,'P'=>611,'Q'=>778,'R'=>722,'S'=>556,'T'=>667,'U'=>722,'V'=>722,'W'=>1000, + 'X'=>722,'Y'=>722,'Z'=>667,'['=>333,'\\'=>278,']'=>333,'^'=>581,'_'=>500,'`'=>333,'a'=>500,'b'=>556,'c'=>444,'d'=>556,'e'=>444,'f'=>333,'g'=>500,'h'=>556,'i'=>278,'j'=>333,'k'=>556,'l'=>278,'m'=>833, + 'n'=>556,'o'=>500,'p'=>556,'q'=>556,'r'=>444,'s'=>389,'t'=>333,'u'=>556,'v'=>500,'w'=>722,'x'=>500,'y'=>500,'z'=>444,'{'=>394,'|'=>220,'}'=>394,'~'=>520,chr(127)=>350,chr(128)=>500,chr(129)=>350,chr(130)=>333,chr(131)=>500, + chr(132)=>500,chr(133)=>1000,chr(134)=>500,chr(135)=>500,chr(136)=>333,chr(137)=>1000,chr(138)=>556,chr(139)=>333,chr(140)=>1000,chr(141)=>350,chr(142)=>667,chr(143)=>350,chr(144)=>350,chr(145)=>333,chr(146)=>333,chr(147)=>500,chr(148)=>500,chr(149)=>350,chr(150)=>500,chr(151)=>1000,chr(152)=>333,chr(153)=>1000, + chr(154)=>389,chr(155)=>333,chr(156)=>722,chr(157)=>350,chr(158)=>444,chr(159)=>722,chr(160)=>250,chr(161)=>333,chr(162)=>500,chr(163)=>500,chr(164)=>500,chr(165)=>500,chr(166)=>220,chr(167)=>500,chr(168)=>333,chr(169)=>747,chr(170)=>300,chr(171)=>500,chr(172)=>570,chr(173)=>333,chr(174)=>747,chr(175)=>333, + chr(176)=>400,chr(177)=>570,chr(178)=>300,chr(179)=>300,chr(180)=>333,chr(181)=>556,chr(182)=>540,chr(183)=>250,chr(184)=>333,chr(185)=>300,chr(186)=>330,chr(187)=>500,chr(188)=>750,chr(189)=>750,chr(190)=>750,chr(191)=>500,chr(192)=>722,chr(193)=>722,chr(194)=>722,chr(195)=>722,chr(196)=>722,chr(197)=>722, + chr(198)=>1000,chr(199)=>722,chr(200)=>667,chr(201)=>667,chr(202)=>667,chr(203)=>667,chr(204)=>389,chr(205)=>389,chr(206)=>389,chr(207)=>389,chr(208)=>722,chr(209)=>722,chr(210)=>778,chr(211)=>778,chr(212)=>778,chr(213)=>778,chr(214)=>778,chr(215)=>570,chr(216)=>778,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>722,chr(222)=>611,chr(223)=>556,chr(224)=>500,chr(225)=>500,chr(226)=>500,chr(227)=>500,chr(228)=>500,chr(229)=>500,chr(230)=>722,chr(231)=>444,chr(232)=>444,chr(233)=>444,chr(234)=>444,chr(235)=>444,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>500,chr(241)=>556, + chr(242)=>500,chr(243)=>500,chr(244)=>500,chr(245)=>500,chr(246)=>500,chr(247)=>570,chr(248)=>500,chr(249)=>556,chr(250)=>556,chr(251)=>556,chr(252)=>556,chr(253)=>500,chr(254)=>556,chr(255)=>500); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/timesbi.php b/app/ThirdParty/fpdf/font/timesbi.php new file mode 100644 index 00000000..089f21ab --- /dev/null +++ b/app/ThirdParty/fpdf/font/timesbi.php @@ -0,0 +1,21 @@ +250,chr(1)=>250,chr(2)=>250,chr(3)=>250,chr(4)=>250,chr(5)=>250,chr(6)=>250,chr(7)=>250,chr(8)=>250,chr(9)=>250,chr(10)=>250,chr(11)=>250,chr(12)=>250,chr(13)=>250,chr(14)=>250,chr(15)=>250,chr(16)=>250,chr(17)=>250,chr(18)=>250,chr(19)=>250,chr(20)=>250,chr(21)=>250, + chr(22)=>250,chr(23)=>250,chr(24)=>250,chr(25)=>250,chr(26)=>250,chr(27)=>250,chr(28)=>250,chr(29)=>250,chr(30)=>250,chr(31)=>250,' '=>250,'!'=>389,'"'=>555,'#'=>500,'$'=>500,'%'=>833,'&'=>778,'\''=>278,'('=>333,')'=>333,'*'=>500,'+'=>570, + ','=>250,'-'=>333,'.'=>250,'/'=>278,'0'=>500,'1'=>500,'2'=>500,'3'=>500,'4'=>500,'5'=>500,'6'=>500,'7'=>500,'8'=>500,'9'=>500,':'=>333,';'=>333,'<'=>570,'='=>570,'>'=>570,'?'=>500,'@'=>832,'A'=>667, + 'B'=>667,'C'=>667,'D'=>722,'E'=>667,'F'=>667,'G'=>722,'H'=>778,'I'=>389,'J'=>500,'K'=>667,'L'=>611,'M'=>889,'N'=>722,'O'=>722,'P'=>611,'Q'=>722,'R'=>667,'S'=>556,'T'=>611,'U'=>722,'V'=>667,'W'=>889, + 'X'=>667,'Y'=>611,'Z'=>611,'['=>333,'\\'=>278,']'=>333,'^'=>570,'_'=>500,'`'=>333,'a'=>500,'b'=>500,'c'=>444,'d'=>500,'e'=>444,'f'=>333,'g'=>500,'h'=>556,'i'=>278,'j'=>278,'k'=>500,'l'=>278,'m'=>778, + 'n'=>556,'o'=>500,'p'=>500,'q'=>500,'r'=>389,'s'=>389,'t'=>278,'u'=>556,'v'=>444,'w'=>667,'x'=>500,'y'=>444,'z'=>389,'{'=>348,'|'=>220,'}'=>348,'~'=>570,chr(127)=>350,chr(128)=>500,chr(129)=>350,chr(130)=>333,chr(131)=>500, + chr(132)=>500,chr(133)=>1000,chr(134)=>500,chr(135)=>500,chr(136)=>333,chr(137)=>1000,chr(138)=>556,chr(139)=>333,chr(140)=>944,chr(141)=>350,chr(142)=>611,chr(143)=>350,chr(144)=>350,chr(145)=>333,chr(146)=>333,chr(147)=>500,chr(148)=>500,chr(149)=>350,chr(150)=>500,chr(151)=>1000,chr(152)=>333,chr(153)=>1000, + chr(154)=>389,chr(155)=>333,chr(156)=>722,chr(157)=>350,chr(158)=>389,chr(159)=>611,chr(160)=>250,chr(161)=>389,chr(162)=>500,chr(163)=>500,chr(164)=>500,chr(165)=>500,chr(166)=>220,chr(167)=>500,chr(168)=>333,chr(169)=>747,chr(170)=>266,chr(171)=>500,chr(172)=>606,chr(173)=>333,chr(174)=>747,chr(175)=>333, + chr(176)=>400,chr(177)=>570,chr(178)=>300,chr(179)=>300,chr(180)=>333,chr(181)=>576,chr(182)=>500,chr(183)=>250,chr(184)=>333,chr(185)=>300,chr(186)=>300,chr(187)=>500,chr(188)=>750,chr(189)=>750,chr(190)=>750,chr(191)=>500,chr(192)=>667,chr(193)=>667,chr(194)=>667,chr(195)=>667,chr(196)=>667,chr(197)=>667, + chr(198)=>944,chr(199)=>667,chr(200)=>667,chr(201)=>667,chr(202)=>667,chr(203)=>667,chr(204)=>389,chr(205)=>389,chr(206)=>389,chr(207)=>389,chr(208)=>722,chr(209)=>722,chr(210)=>722,chr(211)=>722,chr(212)=>722,chr(213)=>722,chr(214)=>722,chr(215)=>570,chr(216)=>722,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>611,chr(222)=>611,chr(223)=>500,chr(224)=>500,chr(225)=>500,chr(226)=>500,chr(227)=>500,chr(228)=>500,chr(229)=>500,chr(230)=>722,chr(231)=>444,chr(232)=>444,chr(233)=>444,chr(234)=>444,chr(235)=>444,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>500,chr(241)=>556, + chr(242)=>500,chr(243)=>500,chr(244)=>500,chr(245)=>500,chr(246)=>500,chr(247)=>570,chr(248)=>500,chr(249)=>556,chr(250)=>556,chr(251)=>556,chr(252)=>556,chr(253)=>444,chr(254)=>500,chr(255)=>444); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/timesi.php b/app/ThirdParty/fpdf/font/timesi.php new file mode 100644 index 00000000..f958b5b6 --- /dev/null +++ b/app/ThirdParty/fpdf/font/timesi.php @@ -0,0 +1,21 @@ +250,chr(1)=>250,chr(2)=>250,chr(3)=>250,chr(4)=>250,chr(5)=>250,chr(6)=>250,chr(7)=>250,chr(8)=>250,chr(9)=>250,chr(10)=>250,chr(11)=>250,chr(12)=>250,chr(13)=>250,chr(14)=>250,chr(15)=>250,chr(16)=>250,chr(17)=>250,chr(18)=>250,chr(19)=>250,chr(20)=>250,chr(21)=>250, + chr(22)=>250,chr(23)=>250,chr(24)=>250,chr(25)=>250,chr(26)=>250,chr(27)=>250,chr(28)=>250,chr(29)=>250,chr(30)=>250,chr(31)=>250,' '=>250,'!'=>333,'"'=>420,'#'=>500,'$'=>500,'%'=>833,'&'=>778,'\''=>214,'('=>333,')'=>333,'*'=>500,'+'=>675, + ','=>250,'-'=>333,'.'=>250,'/'=>278,'0'=>500,'1'=>500,'2'=>500,'3'=>500,'4'=>500,'5'=>500,'6'=>500,'7'=>500,'8'=>500,'9'=>500,':'=>333,';'=>333,'<'=>675,'='=>675,'>'=>675,'?'=>500,'@'=>920,'A'=>611, + 'B'=>611,'C'=>667,'D'=>722,'E'=>611,'F'=>611,'G'=>722,'H'=>722,'I'=>333,'J'=>444,'K'=>667,'L'=>556,'M'=>833,'N'=>667,'O'=>722,'P'=>611,'Q'=>722,'R'=>611,'S'=>500,'T'=>556,'U'=>722,'V'=>611,'W'=>833, + 'X'=>611,'Y'=>556,'Z'=>556,'['=>389,'\\'=>278,']'=>389,'^'=>422,'_'=>500,'`'=>333,'a'=>500,'b'=>500,'c'=>444,'d'=>500,'e'=>444,'f'=>278,'g'=>500,'h'=>500,'i'=>278,'j'=>278,'k'=>444,'l'=>278,'m'=>722, + 'n'=>500,'o'=>500,'p'=>500,'q'=>500,'r'=>389,'s'=>389,'t'=>278,'u'=>500,'v'=>444,'w'=>667,'x'=>444,'y'=>444,'z'=>389,'{'=>400,'|'=>275,'}'=>400,'~'=>541,chr(127)=>350,chr(128)=>500,chr(129)=>350,chr(130)=>333,chr(131)=>500, + chr(132)=>556,chr(133)=>889,chr(134)=>500,chr(135)=>500,chr(136)=>333,chr(137)=>1000,chr(138)=>500,chr(139)=>333,chr(140)=>944,chr(141)=>350,chr(142)=>556,chr(143)=>350,chr(144)=>350,chr(145)=>333,chr(146)=>333,chr(147)=>556,chr(148)=>556,chr(149)=>350,chr(150)=>500,chr(151)=>889,chr(152)=>333,chr(153)=>980, + chr(154)=>389,chr(155)=>333,chr(156)=>667,chr(157)=>350,chr(158)=>389,chr(159)=>556,chr(160)=>250,chr(161)=>389,chr(162)=>500,chr(163)=>500,chr(164)=>500,chr(165)=>500,chr(166)=>275,chr(167)=>500,chr(168)=>333,chr(169)=>760,chr(170)=>276,chr(171)=>500,chr(172)=>675,chr(173)=>333,chr(174)=>760,chr(175)=>333, + chr(176)=>400,chr(177)=>675,chr(178)=>300,chr(179)=>300,chr(180)=>333,chr(181)=>500,chr(182)=>523,chr(183)=>250,chr(184)=>333,chr(185)=>300,chr(186)=>310,chr(187)=>500,chr(188)=>750,chr(189)=>750,chr(190)=>750,chr(191)=>500,chr(192)=>611,chr(193)=>611,chr(194)=>611,chr(195)=>611,chr(196)=>611,chr(197)=>611, + chr(198)=>889,chr(199)=>667,chr(200)=>611,chr(201)=>611,chr(202)=>611,chr(203)=>611,chr(204)=>333,chr(205)=>333,chr(206)=>333,chr(207)=>333,chr(208)=>722,chr(209)=>667,chr(210)=>722,chr(211)=>722,chr(212)=>722,chr(213)=>722,chr(214)=>722,chr(215)=>675,chr(216)=>722,chr(217)=>722,chr(218)=>722,chr(219)=>722, + chr(220)=>722,chr(221)=>556,chr(222)=>611,chr(223)=>500,chr(224)=>500,chr(225)=>500,chr(226)=>500,chr(227)=>500,chr(228)=>500,chr(229)=>500,chr(230)=>667,chr(231)=>444,chr(232)=>444,chr(233)=>444,chr(234)=>444,chr(235)=>444,chr(236)=>278,chr(237)=>278,chr(238)=>278,chr(239)=>278,chr(240)=>500,chr(241)=>500, + chr(242)=>500,chr(243)=>500,chr(244)=>500,chr(245)=>500,chr(246)=>500,chr(247)=>675,chr(248)=>500,chr(249)=>500,chr(250)=>500,chr(251)=>500,chr(252)=>500,chr(253)=>444,chr(254)=>500,chr(255)=>444); +$enc = 'cp1252'; +$uv = array(0=>array(0,128),128=>8364,130=>8218,131=>402,132=>8222,133=>8230,134=>array(8224,2),136=>710,137=>8240,138=>352,139=>8249,140=>338,142=>381,145=>array(8216,2),147=>array(8220,2),149=>8226,150=>array(8211,2),152=>732,153=>8482,154=>353,155=>8250,156=>339,158=>382,159=>376,160=>array(160,96)); +?> diff --git a/app/ThirdParty/fpdf/font/zapfdingbats.php b/app/ThirdParty/fpdf/font/zapfdingbats.php new file mode 100644 index 00000000..7c2cb5e4 --- /dev/null +++ b/app/ThirdParty/fpdf/font/zapfdingbats.php @@ -0,0 +1,20 @@ +0,chr(1)=>0,chr(2)=>0,chr(3)=>0,chr(4)=>0,chr(5)=>0,chr(6)=>0,chr(7)=>0,chr(8)=>0,chr(9)=>0,chr(10)=>0,chr(11)=>0,chr(12)=>0,chr(13)=>0,chr(14)=>0,chr(15)=>0,chr(16)=>0,chr(17)=>0,chr(18)=>0,chr(19)=>0,chr(20)=>0,chr(21)=>0, + chr(22)=>0,chr(23)=>0,chr(24)=>0,chr(25)=>0,chr(26)=>0,chr(27)=>0,chr(28)=>0,chr(29)=>0,chr(30)=>0,chr(31)=>0,' '=>278,'!'=>974,'"'=>961,'#'=>974,'$'=>980,'%'=>719,'&'=>789,'\''=>790,'('=>791,')'=>690,'*'=>960,'+'=>939, + ','=>549,'-'=>855,'.'=>911,'/'=>933,'0'=>911,'1'=>945,'2'=>974,'3'=>755,'4'=>846,'5'=>762,'6'=>761,'7'=>571,'8'=>677,'9'=>763,':'=>760,';'=>759,'<'=>754,'='=>494,'>'=>552,'?'=>537,'@'=>577,'A'=>692, + 'B'=>786,'C'=>788,'D'=>788,'E'=>790,'F'=>793,'G'=>794,'H'=>816,'I'=>823,'J'=>789,'K'=>841,'L'=>823,'M'=>833,'N'=>816,'O'=>831,'P'=>923,'Q'=>744,'R'=>723,'S'=>749,'T'=>790,'U'=>792,'V'=>695,'W'=>776, + 'X'=>768,'Y'=>792,'Z'=>759,'['=>707,'\\'=>708,']'=>682,'^'=>701,'_'=>826,'`'=>815,'a'=>789,'b'=>789,'c'=>707,'d'=>687,'e'=>696,'f'=>689,'g'=>786,'h'=>787,'i'=>713,'j'=>791,'k'=>785,'l'=>791,'m'=>873, + 'n'=>761,'o'=>762,'p'=>762,'q'=>759,'r'=>759,'s'=>892,'t'=>892,'u'=>788,'v'=>784,'w'=>438,'x'=>138,'y'=>277,'z'=>415,'{'=>392,'|'=>392,'}'=>668,'~'=>668,chr(127)=>0,chr(128)=>390,chr(129)=>390,chr(130)=>317,chr(131)=>317, + chr(132)=>276,chr(133)=>276,chr(134)=>509,chr(135)=>509,chr(136)=>410,chr(137)=>410,chr(138)=>234,chr(139)=>234,chr(140)=>334,chr(141)=>334,chr(142)=>0,chr(143)=>0,chr(144)=>0,chr(145)=>0,chr(146)=>0,chr(147)=>0,chr(148)=>0,chr(149)=>0,chr(150)=>0,chr(151)=>0,chr(152)=>0,chr(153)=>0, + chr(154)=>0,chr(155)=>0,chr(156)=>0,chr(157)=>0,chr(158)=>0,chr(159)=>0,chr(160)=>0,chr(161)=>732,chr(162)=>544,chr(163)=>544,chr(164)=>910,chr(165)=>667,chr(166)=>760,chr(167)=>760,chr(168)=>776,chr(169)=>595,chr(170)=>694,chr(171)=>626,chr(172)=>788,chr(173)=>788,chr(174)=>788,chr(175)=>788, + chr(176)=>788,chr(177)=>788,chr(178)=>788,chr(179)=>788,chr(180)=>788,chr(181)=>788,chr(182)=>788,chr(183)=>788,chr(184)=>788,chr(185)=>788,chr(186)=>788,chr(187)=>788,chr(188)=>788,chr(189)=>788,chr(190)=>788,chr(191)=>788,chr(192)=>788,chr(193)=>788,chr(194)=>788,chr(195)=>788,chr(196)=>788,chr(197)=>788, + chr(198)=>788,chr(199)=>788,chr(200)=>788,chr(201)=>788,chr(202)=>788,chr(203)=>788,chr(204)=>788,chr(205)=>788,chr(206)=>788,chr(207)=>788,chr(208)=>788,chr(209)=>788,chr(210)=>788,chr(211)=>788,chr(212)=>894,chr(213)=>838,chr(214)=>1016,chr(215)=>458,chr(216)=>748,chr(217)=>924,chr(218)=>748,chr(219)=>918, + chr(220)=>927,chr(221)=>928,chr(222)=>928,chr(223)=>834,chr(224)=>873,chr(225)=>828,chr(226)=>924,chr(227)=>924,chr(228)=>917,chr(229)=>930,chr(230)=>931,chr(231)=>463,chr(232)=>883,chr(233)=>836,chr(234)=>836,chr(235)=>867,chr(236)=>867,chr(237)=>696,chr(238)=>696,chr(239)=>874,chr(240)=>0,chr(241)=>874, + chr(242)=>760,chr(243)=>946,chr(244)=>771,chr(245)=>865,chr(246)=>771,chr(247)=>888,chr(248)=>967,chr(249)=>888,chr(250)=>831,chr(251)=>873,chr(252)=>927,chr(253)=>970,chr(254)=>918,chr(255)=>0); +$uv = array(32=>32,33=>array(9985,4),37=>9742,38=>array(9990,4),42=>9755,43=>9758,44=>array(9996,28),72=>9733,73=>array(10025,35),108=>9679,109=>10061,110=>9632,111=>array(10063,4),115=>9650,116=>9660,117=>9670,118=>10070,119=>9687,120=>array(10072,7),128=>array(10088,14),161=>array(10081,7),168=>9827,169=>9830,170=>9829,171=>9824,172=>array(9312,10),182=>array(10102,31),213=>8594,214=>array(8596,2),216=>array(10136,24),241=>array(10161,14)); +?> diff --git a/app/ThirdParty/fpdf/fpdf.php b/app/ThirdParty/fpdf/fpdf.php new file mode 100644 index 00000000..3c76bd5c --- /dev/null +++ b/app/ThirdParty/fpdf/fpdf.php @@ -0,0 +1,1819 @@ +fontpath = __DIR__ . '/font/'; + + // Initialization of properties + $this->state = 0; + $this->page = 0; + $this->n = 2; + $this->buffer = ''; + $this->pages = array(); + $this->PageInfo = array(); + $this->fonts = array(); + $this->FontFiles = array(); + $this->encodings = array(); + $this->cmaps = array(); + $this->images = array(); + $this->links = array(); + $this->InHeader = false; + $this->InFooter = false; + $this->lasth = 0; + $this->FontFamily = ''; + $this->FontStyle = ''; + $this->FontSizePt = 12; + $this->underline = false; + $this->DrawColor = '0 G'; + $this->FillColor = '0 g'; + $this->TextColor = '0 g'; + $this->ColorFlag = false; + $this->WithAlpha = false; + $this->ws = 0; + $this->iconv = function_exists('iconv'); + + // Core fonts + $this->CoreFonts = array('courier', 'helvetica', 'times', 'symbol', 'zapfdingbats'); + + // Scale factor + if ($unit == 'pt') + $this->k = 1; + elseif ($unit == 'mm') + $this->k = 72 / 25.4; + elseif ($unit == 'cm') + $this->k = 72 / 2.54; + elseif ($unit == 'in') + $this->k = 72; + else + $this->Error('Incorrect unit: ' . $unit); + + // Page sizes + $this->StdPageSizes = array( + 'a3' => array(841.89, 1190.55), + 'a4' => array(595.28, 841.89), + 'a5' => array(420.94, 595.28), + 'letter' => array(612, 792), + 'legal' => array(612, 1008) + ); + $size = $this->_getpagesize($size); + $this->DefPageSize = $size; + $this->CurPageSize = $size; + + // Page orientation + $orientation = strtolower($orientation); + if ($orientation == 'p' || $orientation == 'portrait') { + $this->DefOrientation = 'P'; + $this->w = $size[0]; + $this->h = $size[1]; + } elseif ($orientation == 'l' || $orientation == 'landscape') { + $this->DefOrientation = 'L'; + $this->w = $size[1]; + $this->h = $size[0]; + } else { + $this->Error('Incorrect orientation: ' . $orientation); + } + $this->CurOrientation = $this->DefOrientation; + $this->wPt = $this->w * $this->k; + $this->hPt = $this->h * $this->k; + + // Page rotation + $this->CurRotation = 0; + + // Margins + $margin = 28.35 / $this->k; + $this->SetMargins($margin, $margin); + $this->cMargin = $margin / 10; + $this->LineWidth = .567 / $this->k; + + // Page break + $this->SetAutoPageBreak(true, 2 * $margin); + + // Display and compression + $this->SetDisplayMode('default'); + $this->SetCompression(true); + + // Metadata + $this->metadata = array('Producer' => 'FPDF ' . self::VERSION); + $this->PDFVersion = '1.3'; + } + + + function SetMargins($left, $top, $right = null) + { + // Set left, top and right margins + $this->lMargin = $left; + $this->tMargin = $top; + if ($right === null) + $right = $left; + $this->rMargin = $right; + } + + function SetLeftMargin($margin) + { + // Set left margin + $this->lMargin = $margin; + if ($this->page > 0 && $this->x < $margin) + $this->x = $margin; + } + + function SetTopMargin($margin) + { + // Set top margin + $this->tMargin = $margin; + } + + function SetRightMargin($margin) + { + // Set right margin + $this->rMargin = $margin; + } + + function SetAutoPageBreak($auto, $margin = 0) + { + // Set auto page break mode and triggering margin + $this->AutoPageBreak = $auto; + $this->bMargin = $margin; + $this->PageBreakTrigger = $this->h - $margin; + } + + function SetDisplayMode($zoom, $layout = 'default') + { + // Set display mode in viewer + if ($zoom == 'fullpage' || $zoom == 'fullwidth' || $zoom == 'real' || $zoom == 'default' || !is_string($zoom)) + $this->ZoomMode = $zoom; + else + $this->Error('Incorrect zoom display mode: ' . $zoom); + if ($layout == 'single' || $layout == 'continuous' || $layout == 'two' || $layout == 'default') + $this->LayoutMode = $layout; + else + $this->Error('Incorrect layout display mode: ' . $layout); + } + + function SetCompression($compress) + { + // Set page compression + if (function_exists('gzcompress')) + $this->compress = $compress; + else + $this->compress = false; + } + + function SetTitle($title, $isUTF8 = false) + { + // Title of document + $this->metadata['Title'] = $isUTF8 ? $title : $this->_UTF8encode($title); + } + + function SetAuthor($author, $isUTF8 = false) + { + // Author of document + $this->metadata['Author'] = $isUTF8 ? $author : $this->_UTF8encode($author); + } + + function SetSubject($subject, $isUTF8 = false) + { + // Subject of document + $this->metadata['Subject'] = $isUTF8 ? $subject : $this->_UTF8encode($subject); + } + + function SetKeywords($keywords, $isUTF8 = false) + { + // Keywords of document + $this->metadata['Keywords'] = $isUTF8 ? $keywords : $this->_UTF8encode($keywords); + } + + function SetCreator($creator, $isUTF8 = false) + { + // Creator of document + $this->metadata['Creator'] = $isUTF8 ? $creator : $this->_UTF8encode($creator); + } + + function AliasNbPages($alias = '{nb}') + { + // Define an alias for total number of pages + $this->AliasNbPages = $alias; + } + + function Error($msg) + { + // Fatal error + throw new Exception('FPDF error: ' . $msg); + } + + function Close() + { + // Terminate document + if ($this->state == 3) + return; + if ($this->page == 0) + $this->AddPage(); + // Page footer + $this->InFooter = true; + $this->Footer(); + $this->InFooter = false; + // Close page + $this->_endpage(); + // Close document + $this->_enddoc(); + } + + function AddPage($orientation = '', $size = '', $rotation = 0) + { + // Start a new page + if ($this->state == 3) + $this->Error('The document is closed'); + $family = $this->FontFamily; + $style = $this->FontStyle . ($this->underline ? 'U' : ''); + $fontsize = $this->FontSizePt; + $lw = $this->LineWidth; + $dc = $this->DrawColor; + $fc = $this->FillColor; + $tc = $this->TextColor; + $cf = $this->ColorFlag; + if ($this->page > 0) { + // Page footer + $this->InFooter = true; + $this->Footer(); + $this->InFooter = false; + // Close page + $this->_endpage(); + } + // Start new page + $this->_beginpage($orientation, $size, $rotation); + // Set line cap style to square + $this->_out('2 J'); + // Set line width + $this->LineWidth = $lw; + $this->_out(sprintf('%.2F w', $lw * $this->k)); + // Set font + if ($family) + $this->SetFont($family, $style, $fontsize); + // Set colors + $this->DrawColor = $dc; + if ($dc != '0 G') + $this->_out($dc); + $this->FillColor = $fc; + if ($fc != '0 g') + $this->_out($fc); + $this->TextColor = $tc; + $this->ColorFlag = $cf; + // Page header + $this->InHeader = true; + $this->Header(); + $this->InHeader = false; + // Restore line width + if ($this->LineWidth != $lw) { + $this->LineWidth = $lw; + $this->_out(sprintf('%.2F w', $lw * $this->k)); + } + // Restore font + if ($family) + $this->SetFont($family, $style, $fontsize); + // Restore colors + if ($this->DrawColor != $dc) { + $this->DrawColor = $dc; + $this->_out($dc); + } + if ($this->FillColor != $fc) { + $this->FillColor = $fc; + $this->_out($fc); + } + $this->TextColor = $tc; + $this->ColorFlag = $cf; + } + + function Header() + { + // To be implemented in your own inherited class + } + + function Footer() + { + // To be implemented in your own inherited class + } + + function PageNo() + { + // Get current page number + return $this->page; + } + + function SetDrawColor($r, $g = null, $b = null) + { + // Set color for all stroking operations + if (($r == 0 && $g == 0 && $b == 0) || $g === null) + $this->DrawColor = sprintf('%.3F G', $r / 255); + else + $this->DrawColor = sprintf('%.3F %.3F %.3F RG', $r / 255, $g / 255, $b / 255); + if ($this->page > 0) + $this->_out($this->DrawColor); + } + + function SetFillColor($r, $g = null, $b = null) + { + // Set color for all filling operations + if (($r == 0 && $g == 0 && $b == 0) || $g === null) + $this->FillColor = sprintf('%.3F g', $r / 255); + else + $this->FillColor = sprintf('%.3F %.3F %.3F rg', $r / 255, $g / 255, $b / 255); + $this->ColorFlag = ($this->FillColor != $this->TextColor); + if ($this->page > 0) + $this->_out($this->FillColor); + } + + function SetTextColor($r, $g = null, $b = null) + { + // Set color for text + if (($r == 0 && $g == 0 && $b == 0) || $g === null) + $this->TextColor = sprintf('%.3F g', $r / 255); + else + $this->TextColor = sprintf('%.3F %.3F %.3F rg', $r / 255, $g / 255, $b / 255); + $this->ColorFlag = ($this->FillColor != $this->TextColor); + } + + function GetStringWidth($s) + { + // Get width of a string in the current font + $cw = $this->CurrentFont['cw']; + $w = 0; + $s = (string)$s; + $l = strlen($s); + for ($i = 0; $i < $l; $i++) + $w += $cw[$s[$i]]; + return $w * $this->FontSize / 1000; + } + + function SetLineWidth($width) + { + // Set line width + $this->LineWidth = $width; + if ($this->page > 0) + $this->_out(sprintf('%.2F w', $width * $this->k)); + } + + function Line($x1, $y1, $x2, $y2) + { + // Draw a line + $this->_out(sprintf('%.2F %.2F m %.2F %.2F l S', $x1 * $this->k, ($this->h - $y1) * $this->k, $x2 * $this->k, ($this->h - $y2) * $this->k)); + } + + function Rect($x, $y, $w, $h, $style = '') + { + // Draw a rectangle + if ($style == 'F') + $op = 'f'; + elseif ($style == 'FD' || $style == 'DF') + $op = 'B'; + else + $op = 'S'; + $this->_out(sprintf('%.2F %.2F %.2F %.2F re %s', $x * $this->k, ($this->h - $y) * $this->k, $w * $this->k, -$h * $this->k, $op)); + } + + function AddFont($family, $style = '', $file = '', $dir = '') + { + // Add a TrueType, OpenType or Type1 font + $family = strtolower($family); + if ($file == '') + $file = str_replace(' ', '', $family) . strtolower($style) . '.php'; + $style = strtoupper($style); + if ($style == 'IB') + $style = 'BI'; + $fontkey = $family . $style; + if (isset($this->fonts[$fontkey])) + return; + if (strpos($file, '/') !== false || strpos($file, "\\") !== false) + $this->Error('Incorrect font definition file name: ' . $file); + if ($dir == '') + $dir = $this->fontpath; + if (substr($dir, -1) != '/' && substr($dir, -1) != '\\') + $dir .= '/'; + $info = $this->_loadfont($dir . $file); + $info['i'] = count($this->fonts) + 1; + if (!empty($info['file'])) { + // Embedded font + $info['file'] = $dir . $info['file']; + if ($info['type'] == 'TrueType') + $this->FontFiles[$info['file']] = array('length1' => $info['originalsize']); + else + $this->FontFiles[$info['file']] = array('length1' => $info['size1'], 'length2' => $info['size2']); + } + $this->fonts[$fontkey] = $info; + } + + function SetFont($family, $style = '', $size = 0) + { + // Select a font; size given in points + if ($family == '') + $family = $this->FontFamily; + else + $family = strtolower($family); + $style = strtoupper($style); + if (strpos($style, 'U') !== false) { + $this->underline = true; + $style = str_replace('U', '', $style); + } else + $this->underline = false; + if ($style == 'IB') + $style = 'BI'; + if ($size == 0) + $size = $this->FontSizePt; + // Test if font is already selected + if ($this->FontFamily == $family && $this->FontStyle == $style && $this->FontSizePt == $size) + return; + // Test if font is already loaded + $fontkey = $family . $style; + if (!isset($this->fonts[$fontkey])) { + // Test if one of the core fonts + if ($family == 'arial') + $family = 'helvetica'; + if (in_array($family, $this->CoreFonts)) { + if ($family == 'symbol' || $family == 'zapfdingbats') + $style = ''; + $fontkey = $family . $style; + if (!isset($this->fonts[$fontkey])) + $this->AddFont($family, $style); + } else + $this->Error('Undefined font: ' . $family . ' ' . $style); + } + // Select it + $this->FontFamily = $family; + $this->FontStyle = $style; + $this->FontSizePt = $size; + $this->FontSize = $size / $this->k; + $this->CurrentFont = $this->fonts[$fontkey]; + if ($this->page > 0) + $this->_out(sprintf('BT /F%d %.2F Tf ET', $this->CurrentFont['i'], $this->FontSizePt)); + } + + function SetFontSize($size) + { + // Set font size in points + if ($this->FontSizePt == $size) + return; + $this->FontSizePt = $size; + $this->FontSize = $size / $this->k; + if ($this->page > 0 && isset($this->CurrentFont)) + $this->_out(sprintf('BT /F%d %.2F Tf ET', $this->CurrentFont['i'], $this->FontSizePt)); + } + + function AddLink() + { + // Create a new internal link + $n = count($this->links) + 1; + $this->links[$n] = array(0, 0); + return $n; + } + + function SetLink($link, $y = 0, $page = -1) + { + // Set destination of internal link + if ($y == -1) + $y = $this->y; + if ($page == -1) + $page = $this->page; + $this->links[$link] = array($page, $y); + } + + function Link($x, $y, $w, $h, $link) + { + // Put a link on the page + $this->PageLinks[$this->page][] = array($x * $this->k, $this->hPt - $y * $this->k, $w * $this->k, $h * $this->k, $link); + } + + function Text($x, $y, $txt) + { + // Output a string + if (!isset($this->CurrentFont)) + $this->Error('No font has been set'); + $txt = (string)$txt; + $s = sprintf('BT %.2F %.2F Td (%s) Tj ET', $x * $this->k, ($this->h - $y) * $this->k, $this->_escape($txt)); + if ($this->underline && $txt !== '') + $s .= ' ' . $this->_dounderline($x, $y, $txt); + if ($this->ColorFlag) + $s = 'q ' . $this->TextColor . ' ' . $s . ' Q'; + $this->_out($s); + } + + function AcceptPageBreak() + { + // Accept automatic page break or not + return $this->AutoPageBreak; + } + + function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = false, $link = '') + { + // Output a cell + $k = $this->k; + if ($this->y + $h > $this->PageBreakTrigger && !$this->InHeader && !$this->InFooter && $this->AcceptPageBreak()) { + // Automatic page break + $x = $this->x; + $ws = $this->ws; + if ($ws > 0) { + $this->ws = 0; + $this->_out('0 Tw'); + } + $this->AddPage($this->CurOrientation, $this->CurPageSize, $this->CurRotation); + $this->x = $x; + if ($ws > 0) { + $this->ws = $ws; + $this->_out(sprintf('%.3F Tw', $ws * $k)); + } + } + if ($w == 0) + $w = $this->w - $this->rMargin - $this->x; + $s = ''; + if ($fill || $border == 1) { + if ($fill) + $op = ($border == 1) ? 'B' : 'f'; + else + $op = 'S'; + $s = sprintf('%.2F %.2F %.2F %.2F re %s ', $this->x * $k, ($this->h - $this->y) * $k, $w * $k, -$h * $k, $op); + } + if (is_string($border)) { + $x = $this->x; + $y = $this->y; + if (strpos($border, 'L') !== false) + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x * $k, ($this->h - $y) * $k, $x * $k, ($this->h - ($y + $h)) * $k); + if (strpos($border, 'T') !== false) + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x * $k, ($this->h - $y) * $k, ($x + $w) * $k, ($this->h - $y) * $k); + if (strpos($border, 'R') !== false) + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', ($x + $w) * $k, ($this->h - $y) * $k, ($x + $w) * $k, ($this->h - ($y + $h)) * $k); + if (strpos($border, 'B') !== false) + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x * $k, ($this->h - ($y + $h)) * $k, ($x + $w) * $k, ($this->h - ($y + $h)) * $k); + } + $txt = (string)$txt; + if ($txt !== '') { + if (!isset($this->CurrentFont)) + $this->Error('No font has been set'); + if ($align == 'R') + $dx = $w - $this->cMargin - $this->GetStringWidth($txt); + elseif ($align == 'C') + $dx = ($w - $this->GetStringWidth($txt)) / 2; + else + $dx = $this->cMargin; + if ($this->ColorFlag) + $s .= 'q ' . $this->TextColor . ' '; + $s .= sprintf('BT %.2F %.2F Td (%s) Tj ET', ($this->x + $dx) * $k, ($this->h - ($this->y + .5 * $h + .3 * $this->FontSize)) * $k, $this->_escape($txt)); + if ($this->underline) + $s .= ' ' . $this->_dounderline($this->x + $dx, $this->y + .5 * $h + .3 * $this->FontSize, $txt); + if ($this->ColorFlag) + $s .= ' Q'; + if ($link) + $this->Link($this->x + $dx, $this->y + .5 * $h - .5 * $this->FontSize, $this->GetStringWidth($txt), $this->FontSize, $link); + } + if ($s) + $this->_out($s); + $this->lasth = $h; + if ($ln > 0) { + // Go to next line + $this->y += $h; + if ($ln == 1) + $this->x = $this->lMargin; + } else + $this->x += $w; + } + + function MultiCell($w, $h, $txt, $border = 0, $align = 'J', $fill = false) + { + // Output text with automatic or explicit line breaks + if (!isset($this->CurrentFont)) + $this->Error('No font has been set'); + $cw = $this->CurrentFont['cw']; + if ($w == 0) + $w = $this->w - $this->rMargin - $this->x; + $wmax = ($w - 2 * $this->cMargin) * 1000 / $this->FontSize; + $s = str_replace("\r", '', (string)$txt); + $nb = strlen($s); + if ($nb > 0 && $s[$nb - 1] == "\n") + $nb--; + $b = 0; + if ($border) { + if ($border == 1) { + $border = 'LTRB'; + $b = 'LRT'; + $b2 = 'LR'; + } else { + $b2 = ''; + if (strpos($border, 'L') !== false) + $b2 .= 'L'; + if (strpos($border, 'R') !== false) + $b2 .= 'R'; + $b = (strpos($border, 'T') !== false) ? $b2 . 'T' : $b2; + } + } + $sep = -1; + $i = 0; + $j = 0; + $l = 0; + $ns = 0; + $nl = 1; + while ($i < $nb) { + // Get next character + $c = $s[$i]; + if ($c == "\n") { + // Explicit line break + if ($this->ws > 0) { + $this->ws = 0; + $this->_out('0 Tw'); + } + $this->Cell($w, $h, substr($s, $j, $i - $j), $b, 2, $align, $fill); + $i++; + $sep = -1; + $j = $i; + $l = 0; + $ns = 0; + $nl++; + if ($border && $nl == 2) + $b = $b2; + continue; + } + if ($c == ' ') { + $sep = $i; + $ls = $l; + $ns++; + } + $l += $cw[$c]; + if ($l > $wmax) { + // Automatic line break + if ($sep == -1) { + if ($i == $j) + $i++; + if ($this->ws > 0) { + $this->ws = 0; + $this->_out('0 Tw'); + } + $this->Cell($w, $h, substr($s, $j, $i - $j), $b, 2, $align, $fill); + } else { + if ($align == 'J') { + $this->ws = ($ns > 1) ? ($wmax - $ls) / 1000 * $this->FontSize / ($ns - 1) : 0; + $this->_out(sprintf('%.3F Tw', $this->ws * $this->k)); + } + $this->Cell($w, $h, substr($s, $j, $sep - $j), $b, 2, $align, $fill); + $i = $sep + 1; + } + $sep = -1; + $j = $i; + $l = 0; + $ns = 0; + $nl++; + if ($border && $nl == 2) + $b = $b2; + } else + $i++; + } + // Last chunk + if ($this->ws > 0) { + $this->ws = 0; + $this->_out('0 Tw'); + } + if ($border && strpos($border, 'B') !== false) + $b .= 'B'; + $this->Cell($w, $h, substr($s, $j, $i - $j), $b, 2, $align, $fill); + $this->x = $this->lMargin; + } + + function Write($h, $txt, $link = '') + { + // Output text in flowing mode + if (!isset($this->CurrentFont)) + $this->Error('No font has been set'); + $cw = $this->CurrentFont['cw']; + $w = $this->w - $this->rMargin - $this->x; + $wmax = ($w - 2 * $this->cMargin) * 1000 / $this->FontSize; + $s = str_replace("\r", '', (string)$txt); + $nb = strlen($s); + $sep = -1; + $i = 0; + $j = 0; + $l = 0; + $nl = 1; + while ($i < $nb) { + // Get next character + $c = $s[$i]; + if ($c == "\n") { + // Explicit line break + $this->Cell($w, $h, substr($s, $j, $i - $j), 0, 2, '', false, $link); + $i++; + $sep = -1; + $j = $i; + $l = 0; + if ($nl == 1) { + $this->x = $this->lMargin; + $w = $this->w - $this->rMargin - $this->x; + $wmax = ($w - 2 * $this->cMargin) * 1000 / $this->FontSize; + } + $nl++; + continue; + } + if ($c == ' ') + $sep = $i; + $l += $cw[$c]; + if ($l > $wmax) { + // Automatic line break + if ($sep == -1) { + if ($this->x > $this->lMargin) { + // Move to next line + $this->x = $this->lMargin; + $this->y += $h; + $w = $this->w - $this->rMargin - $this->x; + $wmax = ($w - 2 * $this->cMargin) * 1000 / $this->FontSize; + $i++; + $nl++; + continue; + } + if ($i == $j) + $i++; + $this->Cell($w, $h, substr($s, $j, $i - $j), 0, 2, '', false, $link); + } else { + $this->Cell($w, $h, substr($s, $j, $sep - $j), 0, 2, '', false, $link); + $i = $sep + 1; + } + $sep = -1; + $j = $i; + $l = 0; + if ($nl == 1) { + $this->x = $this->lMargin; + $w = $this->w - $this->rMargin - $this->x; + $wmax = ($w - 2 * $this->cMargin) * 1000 / $this->FontSize; + } + $nl++; + } else + $i++; + } + // Last chunk + if ($i != $j) + $this->Cell($l / 1000 * $this->FontSize, $h, substr($s, $j), 0, 0, '', false, $link); + } + + function Ln($h = null) + { + // Line feed; default value is the last cell height + $this->x = $this->lMargin; + if ($h === null) + $this->y += $this->lasth; + else + $this->y += $h; + } + + function Image($file, $x = null, $y = null, $w = 0, $h = 0, $type = '', $link = '') + { + // Put an image on the page + if ($file == '') + $this->Error('Image file name is empty'); + if (!isset($this->images[$file])) { + // First use of this image, get info + if ($type == '') { + $pos = strrpos($file, '.'); + if (!$pos) + $this->Error('Image file has no extension and no type was specified: ' . $file); + $type = substr($file, $pos + 1); + } + $type = strtolower($type); + if ($type == 'jpeg') + $type = 'jpg'; + $mtd = '_parse' . $type; + if (!method_exists($this, $mtd)) + $this->Error('Unsupported image type: ' . $type); + $info = $this->$mtd($file); + $info['i'] = count($this->images) + 1; + $this->images[$file] = $info; + } else + $info = $this->images[$file]; + + // Automatic width and height calculation if needed + if ($w == 0 && $h == 0) { + // Put image at 96 dpi + $w = -96; + $h = -96; + } + if ($w < 0) + $w = -$info['w'] * 72 / $w / $this->k; + if ($h < 0) + $h = -$info['h'] * 72 / $h / $this->k; + if ($w == 0) + $w = $h * $info['w'] / $info['h']; + if ($h == 0) + $h = $w * $info['h'] / $info['w']; + + // Flowing mode + if ($y === null) { + if ($this->y + $h > $this->PageBreakTrigger && !$this->InHeader && !$this->InFooter && $this->AcceptPageBreak()) { + // Automatic page break + $x2 = $this->x; + $this->AddPage($this->CurOrientation, $this->CurPageSize, $this->CurRotation); + $this->x = $x2; + } + $y = $this->y; + $this->y += $h; + } + + if ($x === null) + $x = $this->x; + $this->_out(sprintf('q %.2F 0 0 %.2F %.2F %.2F cm /I%d Do Q', $w * $this->k, $h * $this->k, $x * $this->k, ($this->h - ($y + $h)) * $this->k, $info['i'])); + if ($link) + $this->Link($x, $y, $w, $h, $link); + } + + function GetPageWidth() + { + // Get current page width + return $this->w; + } + + function GetPageHeight() + { + // Get current page height + return $this->h; + } + + function GetX() + { + // Get x position + return $this->x; + } + + function SetX($x) + { + // Set x position + if ($x >= 0) + $this->x = $x; + else + $this->x = $this->w + $x; + } + + function GetY() + { + // Get y position + return $this->y; + } + + function SetY($y, $resetX = true) + { + // Set y position and optionally reset x + if ($y >= 0) + $this->y = $y; + else + $this->y = $this->h + $y; + if ($resetX) + $this->x = $this->lMargin; + } + + function SetXY($x, $y) + { + // Set x and y positions + $this->SetX($x); + $this->SetY($y, false); + } + + function Output($dest = '', $name = '', $isUTF8 = false) + { + // Output PDF to some destination + $this->Close(); + if (strlen($name) == 1 && strlen($dest) != 1) { + // Fix parameter order + $tmp = $dest; + $dest = $name; + $name = $tmp; + } + if ($dest == '') + $dest = 'I'; + if ($name == '') + $name = 'doc.pdf'; + switch (strtoupper($dest)) { + case 'I': + // Send to standard output + $this->_checkoutput(); + if (PHP_SAPI != 'cli') { + // We send to a browser + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; ' . $this->_httpencode('filename', $name, $isUTF8)); + header('Cache-Control: private, max-age=0, must-revalidate'); + header('Pragma: public'); + } + echo $this->buffer; + break; + case 'D': + // Download file + $this->_checkoutput(); + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; ' . $this->_httpencode('filename', $name, $isUTF8)); + header('Cache-Control: private, max-age=0, must-revalidate'); + header('Pragma: public'); + echo $this->buffer; + break; + case 'F': + // Save to local file + if (!file_put_contents($name, $this->buffer)) + $this->Error('Unable to create output file: ' . $name); + break; + case 'S': + // Return as a string + return $this->buffer; + default: + $this->Error('Incorrect output destination: ' . $dest); + } + return ''; + } + + /******************************************************************************* + * Protected methods * + *******************************************************************************/ + + protected function _checkoutput() + { + if (PHP_SAPI != 'cli') { + if (headers_sent($file, $line)) + $this->Error("Some data has already been output, can't send PDF file (output started at $file:$line)"); + } + if (ob_get_length()) { + // The output buffer is not empty + if (preg_match('/^(\xEF\xBB\xBF)?\s*$/', ob_get_contents())) { + // It contains only a UTF-8 BOM and/or whitespace, let's clean it + ob_clean(); + } else + $this->Error("Some data has already been output, can't send PDF file"); + } + } + + protected function _getpagesize($size) + { + if (is_string($size)) { + $size = strtolower($size); + if (!isset($this->StdPageSizes[$size])) + $this->Error('Unknown page size: ' . $size); + $a = $this->StdPageSizes[$size]; + return array($a[0] / $this->k, $a[1] / $this->k); + } else { + if ($size[0] > $size[1]) + return array($size[1], $size[0]); + else + return $size; + } + } + + protected function _beginpage($orientation, $size, $rotation) + { + $this->page++; + $this->pages[$this->page] = ''; + $this->PageLinks[$this->page] = array(); + $this->state = 2; + $this->x = $this->lMargin; + $this->y = $this->tMargin; + $this->FontFamily = ''; + // Check page size and orientation + if ($orientation == '') + $orientation = $this->DefOrientation; + else + $orientation = strtoupper($orientation[0]); + if ($size == '') + $size = $this->DefPageSize; + else + $size = $this->_getpagesize($size); + if ($orientation != $this->CurOrientation || $size[0] != $this->CurPageSize[0] || $size[1] != $this->CurPageSize[1]) { + // New size or orientation + if ($orientation == 'P') { + $this->w = $size[0]; + $this->h = $size[1]; + } else { + $this->w = $size[1]; + $this->h = $size[0]; + } + $this->wPt = $this->w * $this->k; + $this->hPt = $this->h * $this->k; + $this->PageBreakTrigger = $this->h - $this->bMargin; + $this->CurOrientation = $orientation; + $this->CurPageSize = $size; + } + if ($orientation != $this->DefOrientation || $size[0] != $this->DefPageSize[0] || $size[1] != $this->DefPageSize[1]) + $this->PageInfo[$this->page]['size'] = array($this->wPt, $this->hPt); + if ($rotation != 0) { + if ($rotation % 90 != 0) + $this->Error('Incorrect rotation value: ' . $rotation); + $this->PageInfo[$this->page]['rotation'] = $rotation; + } + $this->CurRotation = $rotation; + } + + protected function _endpage() + { + $this->state = 1; + } + + protected function _loadfont($path) + { + // Load a font definition file + include($path); + if (!isset($name)) + $this->Error('Could not include font definition file: ' . $path); + if (isset($enc)) + $enc = strtolower($enc); + if (!isset($subsetted)) + $subsetted = false; + return get_defined_vars(); + } + + protected function _isascii($s) + { + // Test if string is ASCII + $nb = strlen($s); + for ($i = 0; $i < $nb; $i++) { + if (ord($s[$i]) > 127) + return false; + } + return true; + } + + protected function _httpencode($param, $value, $isUTF8) + { + // Encode HTTP header field parameter + if ($this->_isascii($value)) + return $param . '="' . $value . '"'; + if (!$isUTF8) + $value = $this->_UTF8encode($value); + return $param . "*=UTF-8''" . rawurlencode($value); + } + + protected function _UTF8encode($s) + { + // Convert ISO-8859-1 to UTF-8 + if ($this->iconv) + return iconv('ISO-8859-1', 'UTF-8', $s); + $res = ''; + $nb = strlen($s); + for ($i = 0; $i < $nb; $i++) { + $c = $s[$i]; + $v = ord($c); + if ($v >= 128) { + $res .= chr(0xC0 | ($v >> 6)); + $res .= chr(0x80 | ($v & 0x3F)); + } else + $res .= $c; + } + return $res; + } + + protected function _UTF8toUTF16($s) + { + // Convert UTF-8 to UTF-16BE with BOM + $res = "\xFE\xFF"; + if ($this->iconv) + return $res . iconv('UTF-8', 'UTF-16BE', $s); + $nb = strlen($s); + $i = 0; + while ($i < $nb) { + $c1 = ord($s[$i++]); + if ($c1 >= 224) { + // 3-byte character + $c2 = ord($s[$i++]); + $c3 = ord($s[$i++]); + $res .= chr((($c1 & 0x0F) << 4) + (($c2 & 0x3C) >> 2)); + $res .= chr((($c2 & 0x03) << 6) + ($c3 & 0x3F)); + } elseif ($c1 >= 192) { + // 2-byte character + $c2 = ord($s[$i++]); + $res .= chr(($c1 & 0x1C) >> 2); + $res .= chr((($c1 & 0x03) << 6) + ($c2 & 0x3F)); + } else { + // Single-byte character + $res .= "\0" . chr($c1); + } + } + return $res; + } + + protected function _escape($s) + { + // Escape special characters + if (strpos($s, '(') !== false || strpos($s, ')') !== false || strpos($s, '\\') !== false || strpos($s, "\r") !== false) + return str_replace(array('\\', '(', ')', "\r"), array('\\\\', '\\(', '\\)', '\\r'), $s); + else + return $s; + } + + protected function _textstring($s) + { + // Format a text string + if (!$this->_isascii($s)) + $s = $this->_UTF8toUTF16($s); + return '(' . $this->_escape($s) . ')'; + } + + protected function _dounderline($x, $y, $txt) + { + // Underline text + $up = $this->CurrentFont['up']; + $ut = $this->CurrentFont['ut']; + $w = $this->GetStringWidth($txt) + $this->ws * substr_count($txt, ' '); + return sprintf('%.2F %.2F %.2F %.2F re f', $x * $this->k, ($this->h - ($y - $up / 1000 * $this->FontSize)) * $this->k, $w * $this->k, -$ut / 1000 * $this->FontSizePt); + } + + protected function _parsejpg($file) + { + // Extract info from a JPEG file + $a = getimagesize($file); + if (!$a) + $this->Error('Missing or incorrect image file: ' . $file); + if ($a[2] != 2) + $this->Error('Not a JPEG file: ' . $file); + if (!isset($a['channels']) || $a['channels'] == 3) + $colspace = 'DeviceRGB'; + elseif ($a['channels'] == 4) + $colspace = 'DeviceCMYK'; + else + $colspace = 'DeviceGray'; + $bpc = isset($a['bits']) ? $a['bits'] : 8; + $data = file_get_contents($file); + return array('w' => $a[0], 'h' => $a[1], 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'DCTDecode', 'data' => $data); + } + + protected function _parsepng($file) + { + // Extract info from a PNG file + $f = fopen($file, 'rb'); + if (!$f) + $this->Error('Can\'t open image file: ' . $file); + $info = $this->_parsepngstream($f, $file); + fclose($f); + return $info; + } + + protected function _parsepngstream($f, $file) + { + // Check signature + if ($this->_readstream($f, 8) != chr(137) . 'PNG' . chr(13) . chr(10) . chr(26) . chr(10)) + $this->Error('Not a PNG file: ' . $file); + + // Read header chunk + $this->_readstream($f, 4); + if ($this->_readstream($f, 4) != 'IHDR') + $this->Error('Incorrect PNG file: ' . $file); + $w = $this->_readint($f); + $h = $this->_readint($f); + $bpc = ord($this->_readstream($f, 1)); + if ($bpc > 8) + $this->Error('16-bit depth not supported: ' . $file); + $ct = ord($this->_readstream($f, 1)); + if ($ct == 0 || $ct == 4) + $colspace = 'DeviceGray'; + elseif ($ct == 2 || $ct == 6) + $colspace = 'DeviceRGB'; + elseif ($ct == 3) + $colspace = 'Indexed'; + else + $this->Error('Unknown color type: ' . $file); + if (ord($this->_readstream($f, 1)) != 0) + $this->Error('Unknown compression method: ' . $file); + if (ord($this->_readstream($f, 1)) != 0) + $this->Error('Unknown filter method: ' . $file); + if (ord($this->_readstream($f, 1)) != 0) + $this->Error('Interlacing not supported: ' . $file); + $this->_readstream($f, 4); + $dp = '/Predictor 15 /Colors ' . ($colspace == 'DeviceRGB' ? 3 : 1) . ' /BitsPerComponent ' . $bpc . ' /Columns ' . $w; + + // Scan chunks looking for palette, transparency and image data + $pal = ''; + $trns = ''; + $data = ''; + do { + $n = $this->_readint($f); + $type = $this->_readstream($f, 4); + if ($type == 'PLTE') { + // Read palette + $pal = $this->_readstream($f, $n); + $this->_readstream($f, 4); + } elseif ($type == 'tRNS') { + // Read transparency info + $t = $this->_readstream($f, $n); + if ($ct == 0) + $trns = array(ord(substr($t, 1, 1))); + elseif ($ct == 2) + $trns = array(ord(substr($t, 1, 1)), ord(substr($t, 3, 1)), ord(substr($t, 5, 1))); + else { + $pos = strpos($t, chr(0)); + if ($pos !== false) + $trns = array($pos); + } + $this->_readstream($f, 4); + } elseif ($type == 'IDAT') { + // Read image data block + $data .= $this->_readstream($f, $n); + $this->_readstream($f, 4); + } elseif ($type == 'IEND') + break; + else + $this->_readstream($f, $n + 4); + } while ($n); + + if ($colspace == 'Indexed' && empty($pal)) + $this->Error('Missing palette in ' . $file); + $info = array('w' => $w, 'h' => $h, 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'FlateDecode', 'dp' => $dp, 'pal' => $pal, 'trns' => $trns); + if ($ct >= 4) { + // Extract alpha channel + if (!function_exists('gzuncompress')) + $this->Error('Zlib not available, can\'t handle alpha channel: ' . $file); + $data = gzuncompress($data); + $color = ''; + $alpha = ''; + if ($ct == 4) { + // Gray image + $len = 2 * $w; + for ($i = 0; $i < $h; $i++) { + $pos = (1 + $len) * $i; + $color .= $data[$pos]; + $alpha .= $data[$pos]; + $line = substr($data, $pos + 1, $len); + $color .= preg_replace('/(.)./s', '$1', $line); + $alpha .= preg_replace('/.(.)/s', '$1', $line); + } + } else { + // RGB image + $len = 4 * $w; + for ($i = 0; $i < $h; $i++) { + $pos = (1 + $len) * $i; + $color .= $data[$pos]; + $alpha .= $data[$pos]; + $line = substr($data, $pos + 1, $len); + $color .= preg_replace('/(.{3})./s', '$1', $line); + $alpha .= preg_replace('/.{3}(.)/s', '$1', $line); + } + } + unset($data); + $data = gzcompress($color); + $info['smask'] = gzcompress($alpha); + $this->WithAlpha = true; + if ($this->PDFVersion < '1.4') + $this->PDFVersion = '1.4'; + } + $info['data'] = $data; + return $info; + } + + protected function _readstream($f, $n) + { + // Read n bytes from stream + $res = ''; + while ($n > 0 && !feof($f)) { + $s = fread($f, $n); + if ($s === false) + $this->Error('Error while reading stream'); + $n -= strlen($s); + $res .= $s; + } + if ($n > 0) + $this->Error('Unexpected end of stream'); + return $res; + } + + protected function _readint($f) + { + // Read a 4-byte integer from stream + $a = unpack('Ni', $this->_readstream($f, 4)); + return $a['i']; + } + + protected function _parsegif($file) + { + // Extract info from a GIF file (via PNG conversion) + if (!function_exists('imagepng')) + $this->Error('GD extension is required for GIF support'); + if (!function_exists('imagecreatefromgif')) + $this->Error('GD has no GIF read support'); + $im = imagecreatefromgif($file); + if (!$im) + $this->Error('Missing or incorrect image file: ' . $file); + imageinterlace($im, 0); + ob_start(); + imagepng($im); + $data = ob_get_clean(); + imagedestroy($im); + $f = fopen('php://temp', 'rb+'); + if (!$f) + $this->Error('Unable to create memory stream'); + fwrite($f, $data); + rewind($f); + $info = $this->_parsepngstream($f, $file); + fclose($f); + return $info; + } + + protected function _out($s) + { + // Add a line to the current page + if ($this->state == 2) + $this->pages[$this->page] .= $s . "\n"; + elseif ($this->state == 0) + $this->Error('No page has been added yet'); + elseif ($this->state == 1) + $this->Error('Invalid call'); + elseif ($this->state == 3) + $this->Error('The document is closed'); + } + + protected function _put($s) + { + // Add a line to the document + $this->buffer .= $s . "\n"; + } + + protected function _getoffset() + { + return strlen($this->buffer); + } + + protected function _newobj($n = null) + { + // Begin a new object + if ($n === null) + $n = ++$this->n; + $this->offsets[$n] = $this->_getoffset(); + $this->_put($n . ' 0 obj'); + } + + protected function _putstream($data) + { + $this->_put('stream'); + $this->_put($data); + $this->_put('endstream'); + } + + protected function _putstreamobject($data) + { + if ($this->compress) { + $entries = '/Filter /FlateDecode '; + $data = gzcompress($data); + } else + $entries = ''; + $entries .= '/Length ' . strlen($data); + $this->_newobj(); + $this->_put('<<' . $entries . '>>'); + $this->_putstream($data); + $this->_put('endobj'); + } + + protected function _putlinks($n) + { + foreach ($this->PageLinks[$n] as $pl) { + $this->_newobj(); + $rect = sprintf('%.2F %.2F %.2F %.2F', $pl[0], $pl[1], $pl[0] + $pl[2], $pl[1] - $pl[3]); + $s = '<_textstring($pl[4]) . '>>>>'; + else { + $l = $this->links[$pl[4]]; + if (isset($this->PageInfo[$l[0]]['size'])) + $h = $this->PageInfo[$l[0]]['size'][1]; + else + $h = ($this->DefOrientation == 'P') ? $this->DefPageSize[1] * $this->k : $this->DefPageSize[0] * $this->k; + $s .= sprintf('/Dest [%d 0 R /XYZ 0 %.2F null]>>', $this->PageInfo[$l[0]]['n'], $h - $l[1] * $this->k); + } + $this->_put($s); + $this->_put('endobj'); + } + } + + protected function _putpage($n) + { + $this->_newobj(); + $this->_put('<_put('/Parent 1 0 R'); + if (isset($this->PageInfo[$n]['size'])) + $this->_put(sprintf('/MediaBox [0 0 %.2F %.2F]', $this->PageInfo[$n]['size'][0], $this->PageInfo[$n]['size'][1])); + if (isset($this->PageInfo[$n]['rotation'])) + $this->_put('/Rotate ' . $this->PageInfo[$n]['rotation']); + $this->_put('/Resources 2 0 R'); + if (!empty($this->PageLinks[$n])) { + $s = '/Annots ['; + foreach ($this->PageLinks[$n] as $pl) + $s .= $pl[5] . ' 0 R '; + $s .= ']'; + $this->_put($s); + } + if ($this->WithAlpha) + $this->_put('/Group <>'); + $this->_put('/Contents ' . ($this->n + 1) . ' 0 R>>'); + $this->_put('endobj'); + // Page content + if (!empty($this->AliasNbPages)) + $this->pages[$n] = str_replace($this->AliasNbPages, $this->page, $this->pages[$n]); + $this->_putstreamobject($this->pages[$n]); + // Link annotations + $this->_putlinks($n); + } + + protected function _putpages() + { + $nb = $this->page; + $n = $this->n; + for ($i = 1; $i <= $nb; $i++) { + $this->PageInfo[$i]['n'] = ++$n; + $n++; + foreach ($this->PageLinks[$i] as &$pl) + $pl[5] = ++$n; + unset($pl); + } + for ($i = 1; $i <= $nb; $i++) + $this->_putpage($i); + // Pages root + $this->_newobj(1); + $this->_put('<PageInfo[$i]['n'] . ' 0 R '; + $kids .= ']'; + $this->_put($kids); + $this->_put('/Count ' . $nb); + if ($this->DefOrientation == 'P') { + $w = $this->DefPageSize[0]; + $h = $this->DefPageSize[1]; + } else { + $w = $this->DefPageSize[1]; + $h = $this->DefPageSize[0]; + } + $this->_put(sprintf('/MediaBox [0 0 %.2F %.2F]', $w * $this->k, $h * $this->k)); + $this->_put('>>'); + $this->_put('endobj'); + } + + protected function _putfonts() + { + foreach ($this->FontFiles as $file => $info) { + // Font file embedding + $this->_newobj(); + $this->FontFiles[$file]['n'] = $this->n; + $font = file_get_contents($file); + if (!$font) + $this->Error('Font file not found: ' . $file); + $compressed = (substr($file, -2) == '.z'); + if (!$compressed && isset($info['length2'])) + $font = substr($font, 6, $info['length1']) . substr($font, 6 + $info['length1'] + 6, $info['length2']); + $this->_put('<_put('/Filter /FlateDecode'); + $this->_put('/Length1 ' . $info['length1']); + if (isset($info['length2'])) + $this->_put('/Length2 ' . $info['length2'] . ' /Length3 0'); + $this->_put('>>'); + $this->_putstream($font); + $this->_put('endobj'); + } + foreach ($this->fonts as $k => $font) { + // Encoding + if (isset($font['diff'])) { + if (!isset($this->encodings[$font['enc']])) { + $this->_newobj(); + $this->_put('<>'); + $this->_put('endobj'); + $this->encodings[$font['enc']] = $this->n; + } + } + // ToUnicode CMap + if (isset($font['uv'])) { + if (isset($font['enc'])) + $cmapkey = $font['enc']; + else + $cmapkey = $font['name']; + if (!isset($this->cmaps[$cmapkey])) { + $cmap = $this->_tounicodecmap($font['uv']); + $this->_putstreamobject($cmap); + $this->cmaps[$cmapkey] = $this->n; + } + } + // Font object + $this->fonts[$k]['n'] = $this->n + 1; + $type = $font['type']; + $name = $font['name']; + if ($font['subsetted']) + $name = 'AAAAAA+' . $name; + if ($type == 'Core') { + // Core font + $this->_newobj(); + $this->_put('<_put('/BaseFont /' . $name); + $this->_put('/Subtype /Type1'); + if ($name != 'Symbol' && $name != 'ZapfDingbats') + $this->_put('/Encoding /WinAnsiEncoding'); + if (isset($font['uv'])) + $this->_put('/ToUnicode ' . $this->cmaps[$cmapkey] . ' 0 R'); + $this->_put('>>'); + $this->_put('endobj'); + } elseif ($type == 'Type1' || $type == 'TrueType') { + // Additional Type1 or TrueType/OpenType font + $this->_newobj(); + $this->_put('<_put('/BaseFont /' . $name); + $this->_put('/Subtype /' . $type); + $this->_put('/FirstChar 32 /LastChar 255'); + $this->_put('/Widths ' . ($this->n + 1) . ' 0 R'); + $this->_put('/FontDescriptor ' . ($this->n + 2) . ' 0 R'); + if (isset($font['diff'])) + $this->_put('/Encoding ' . $this->encodings[$font['enc']] . ' 0 R'); + else + $this->_put('/Encoding /WinAnsiEncoding'); + if (isset($font['uv'])) + $this->_put('/ToUnicode ' . $this->cmaps[$cmapkey] . ' 0 R'); + $this->_put('>>'); + $this->_put('endobj'); + // Widths + $this->_newobj(); + $cw = $font['cw']; + $s = '['; + for ($i = 32; $i <= 255; $i++) + $s .= $cw[chr($i)] . ' '; + $this->_put($s . ']'); + $this->_put('endobj'); + // Descriptor + $this->_newobj(); + $s = '< $v) + $s .= ' /' . $k . ' ' . $v; + if (!empty($font['file'])) + $s .= ' /FontFile' . ($type == 'Type1' ? '' : '2') . ' ' . $this->FontFiles[$font['file']]['n'] . ' 0 R'; + $this->_put($s . '>>'); + $this->_put('endobj'); + } else { + // Allow for additional types + $mtd = '_put' . strtolower($type); + if (!method_exists($this, $mtd)) + $this->Error('Unsupported font type: ' . $type); + $this->$mtd($font); + } + } + } + + protected function _tounicodecmap($uv) + { + $ranges = ''; + $nbr = 0; + $chars = ''; + $nbc = 0; + foreach ($uv as $c => $v) { + if (is_array($v)) { + $ranges .= sprintf("<%02X> <%02X> <%04X>\n", $c, $c + $v[1] - 1, $v[0]); + $nbr++; + } else { + $chars .= sprintf("<%02X> <%04X>\n", $c, $v); + $nbc++; + } + } + $s = "/CIDInit /ProcSet findresource begin\n"; + $s .= "12 dict begin\n"; + $s .= "begincmap\n"; + $s .= "/CIDSystemInfo\n"; + $s .= "< 0) { + $s .= "$nbr beginbfrange\n"; + $s .= $ranges; + $s .= "endbfrange\n"; + } + if ($nbc > 0) { + $s .= "$nbc beginbfchar\n"; + $s .= $chars; + $s .= "endbfchar\n"; + } + $s .= "endcmap\n"; + $s .= "CMapName currentdict /CMap defineresource pop\n"; + $s .= "end\n"; + $s .= "end"; + return $s; + } + + protected function _putimages() + { + foreach (array_keys($this->images) as $file) { + $this->_putimage($this->images[$file]); + unset($this->images[$file]['data']); + unset($this->images[$file]['smask']); + } + } + + protected function _putimage(&$info) + { + $this->_newobj(); + $info['n'] = $this->n; + $this->_put('<_put('/Subtype /Image'); + $this->_put('/Width ' . $info['w']); + $this->_put('/Height ' . $info['h']); + if ($info['cs'] == 'Indexed') + $this->_put('/ColorSpace [/Indexed /DeviceRGB ' . (strlen($info['pal']) / 3 - 1) . ' ' . ($this->n + 1) . ' 0 R]'); + else { + $this->_put('/ColorSpace /' . $info['cs']); + if ($info['cs'] == 'DeviceCMYK') + $this->_put('/Decode [1 0 1 0 1 0 1 0]'); + } + $this->_put('/BitsPerComponent ' . $info['bpc']); + if (isset($info['f'])) + $this->_put('/Filter /' . $info['f']); + if (isset($info['dp'])) + $this->_put('/DecodeParms <<' . $info['dp'] . '>>'); + if (isset($info['trns']) && is_array($info['trns'])) { + $trns = ''; + for ($i = 0; $i < count($info['trns']); $i++) + $trns .= $info['trns'][$i] . ' ' . $info['trns'][$i] . ' '; + $this->_put('/Mask [' . $trns . ']'); + } + if (isset($info['smask'])) + $this->_put('/SMask ' . ($this->n + 1) . ' 0 R'); + $this->_put('/Length ' . strlen($info['data']) . '>>'); + $this->_putstream($info['data']); + $this->_put('endobj'); + // Soft mask + if (isset($info['smask'])) { + $dp = '/Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns ' . $info['w']; + $smask = array('w' => $info['w'], 'h' => $info['h'], 'cs' => 'DeviceGray', 'bpc' => 8, 'f' => $info['f'], 'dp' => $dp, 'data' => $info['smask']); + $this->_putimage($smask); + } + // Palette + if ($info['cs'] == 'Indexed') + $this->_putstreamobject($info['pal']); + } + + protected function _putxobjectdict() + { + foreach ($this->images as $image) + $this->_put('/I' . $image['i'] . ' ' . $image['n'] . ' 0 R'); + } + + protected function _putresourcedict() + { + $this->_put('/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]'); + $this->_put('/Font <<'); + foreach ($this->fonts as $font) + $this->_put('/F' . $font['i'] . ' ' . $font['n'] . ' 0 R'); + $this->_put('>>'); + $this->_put('/XObject <<'); + $this->_putxobjectdict(); + $this->_put('>>'); + } + + protected function _putresources() + { + $this->_putfonts(); + $this->_putimages(); + // Resource dictionary + $this->_newobj(2); + $this->_put('<<'); + $this->_putresourcedict(); + $this->_put('>>'); + $this->_put('endobj'); + } + + protected function _putinfo() + { + $date = @date('YmdHisO', $this->CreationDate); + $this->metadata['CreationDate'] = 'D:' . substr($date, 0, -2) . "'" . substr($date, -2) . "'"; + foreach ($this->metadata as $key => $value) + $this->_put('/' . $key . ' ' . $this->_textstring($value)); + } + + protected function _putcatalog() + { + $n = $this->PageInfo[1]['n']; + $this->_put('/Type /Catalog'); + $this->_put('/Pages 1 0 R'); + if ($this->ZoomMode == 'fullpage') + $this->_put('/OpenAction [' . $n . ' 0 R /Fit]'); + elseif ($this->ZoomMode == 'fullwidth') + $this->_put('/OpenAction [' . $n . ' 0 R /FitH null]'); + elseif ($this->ZoomMode == 'real') + $this->_put('/OpenAction [' . $n . ' 0 R /XYZ null null 1]'); + elseif (!is_string($this->ZoomMode)) + $this->_put('/OpenAction [' . $n . ' 0 R /XYZ null null ' . sprintf('%.2F', $this->ZoomMode / 100) . ']'); + if ($this->LayoutMode == 'single') + $this->_put('/PageLayout /SinglePage'); + elseif ($this->LayoutMode == 'continuous') + $this->_put('/PageLayout /OneColumn'); + elseif ($this->LayoutMode == 'two') + $this->_put('/PageLayout /TwoColumnLeft'); + } + + protected function _putheader() + { + $this->_put('%PDF-' . $this->PDFVersion); + } + + protected function _puttrailer() + { + $this->_put('/Size ' . ($this->n + 1)); + $this->_put('/Root ' . $this->n . ' 0 R'); + $this->_put('/Info ' . ($this->n - 1) . ' 0 R'); + } + + protected function _enddoc() + { + $this->CreationDate = time(); + $this->_putheader(); + $this->_putpages(); + $this->_putresources(); + // Info + $this->_newobj(); + $this->_put('<<'); + $this->_putinfo(); + $this->_put('>>'); + $this->_put('endobj'); + // Catalog + $this->_newobj(); + $this->_put('<<'); + $this->_putcatalog(); + $this->_put('>>'); + $this->_put('endobj'); + // Cross-ref + $offset = $this->_getoffset(); + $this->_put('xref'); + $this->_put('0 ' . ($this->n + 1)); + $this->_put('0000000000 65535 f '); + for ($i = 1; $i <= $this->n; $i++) + $this->_put(sprintf('%010d 00000 n ', $this->offsets[$i])); + // Trailer + $this->_put('trailer'); + $this->_put('<<'); + $this->_puttrailer(); + $this->_put('>>'); + $this->_put('startxref'); + $this->_put($offset); + $this->_put('%%EOF'); + $this->state = 3; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php old mode 100755 new mode 100644 index f916d222..dd11725f --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ \App\Http\Middleware\PrimeTimezone::class, 'cleanup.scheduler' => \App\Http\Middleware\CleanupScheduler::class, 'auth.docs' => \App\Http\Middleware\ApiDocsAuth::class, + 'teacher.portal.auth' => \App\Http\Middleware\TeacherPortalAuthenticate::class, + 'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class, + 'parent.progress' => \App\Http\Middleware\EnsureParentProgressAccess::class, + 'print_requests.teacher' => \App\Http\Middleware\EnsurePrintRequestsTeacherAccess::class, + 'print_requests.admin' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class, + 'badge_scan.logs' => \App\Http\Middleware\EnsureBadgeScanLogsAccess::class, ]); $middleware->redirectGuestsTo(function (Request $request) { @@ -30,7 +37,7 @@ return Application::configure(basePath: dirname(__DIR__)) return null; } - return route('login'); + return app(ApplicationUrlService::class)->webLoginUrl(); }); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore old mode 100755 new mode 100644 diff --git a/bootstrap/providers.php b/bootstrap/providers.php old mode 100755 new mode 100644 diff --git a/composer.json b/composer.json index dff1b6fe..ff7b9110 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "php-open-source-saver/jwt-auth": "^2.8", - "setasign/fpdf": "*" + "setasign/fpdf": "*", + "chillerlan/php-qrcode": "^5.0" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -55,6 +56,11 @@ "@php artisan config:clear --ansi", "@php artisan test" ], + "production": [ + "@composer install --no-dev --optimize-autoloader --no-interaction", + "@php artisan optimize" + ], + "ci-controller-map": "@php tools/generate-ci-controller-mapping.php", "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/composer.lock b/composer.lock index a238a69b..d1106d97 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "155e8c52495d21620105d90faabd1d17", + "content-hash": "b2966364d6a0f8b7a445116ac851814b", "packages": [ { "name": "brick/math", @@ -135,6 +135,168 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "chillerlan/php-qrcode", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/7b66282572fc14075c0507d74d9837dab25b38d6", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.5.2", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.23.0", + "squizlabs/php_codesniffer": "^4.0.0" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2025-11-23T23:51:44+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phan/phan": "^5.5.2", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^10.5", + "slevomat/coding-standard": "^8.22", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper", + "property hook" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2026-03-20T21:10:52+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -852,16 +1014,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -877,6 +1039,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -948,7 +1111,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -964,7 +1127,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1054,16 +1217,16 @@ }, { "name": "laravel/framework", - "version": "v12.52.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7" + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/d5511fa74f4608dbb99864198b1954042aa8d5a7", - "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7", + "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", "shasum": "" }, "require": { @@ -1084,7 +1247,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1179,7 +1342,7 @@ "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1.41", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0|^1.0", @@ -1272,20 +1435,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-17T17:07:04+00:00" + "time": "2026-03-18T14:28:59+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.13", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { @@ -1329,9 +1492,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.13" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-02-06T12:17:10+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/sanctum", @@ -1398,16 +1561,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.9", + "version": "v2.0.10", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { @@ -1455,7 +1618,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-03T06:55:34+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/tinker", @@ -1598,16 +1761,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -1632,9 +1795,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -1701,7 +1864,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -1787,16 +1950,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -1864,9 +2027,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-local", @@ -1975,20 +2138,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2061,7 +2224,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -2069,20 +2232,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -2145,7 +2308,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -2153,7 +2316,7 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "monolog/monolog", @@ -2327,16 +2490,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.11.3", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", "shasum": "" }, "require": { @@ -2428,7 +2591,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-03-11T17:23:39+00:00" }, { "name": "nette/schema", @@ -3318,16 +3481,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.20", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -3391,9 +3554,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-02-11T15:05:28+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -3595,18 +3758,22 @@ }, { "name": "setasign/fpdf", - "version": "1.8.2", + "version": "1.8.6", "source": { "type": "git", "url": "https://github.com/Setasign/FPDF.git", - "reference": "d77904018090c17dc9f3ab6e944679a7a47e710a" + "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDF/zipball/d77904018090c17dc9f3ab6e944679a7a47e710a", - "reference": "d77904018090c17dc9f3ab6e944679a7a47e710a", + "url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", + "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", "shasum": "" }, + "require": { + "ext-gd": "*", + "ext-zlib": "*" + }, "type": "library", "autoload": { "classmap": [ @@ -3631,9 +3798,9 @@ "pdf" ], "support": { - "source": "https://github.com/Setasign/FPDF/tree/1.8.2" + "source": "https://github.com/Setasign/FPDF/tree/1.8.6" }, - "time": "2019-12-08T10:32:10+00:00" + "time": "2023-06-26T14:44:25+00:00" }, { "name": "symfony/clock", @@ -3715,16 +3882,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -3789,7 +3956,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -3809,20 +3976,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.0", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", "shasum": "" }, "require": { @@ -3858,7 +4025,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.0" + "source": "https://github.com/symfony/css-selector/tree/v7.4.6" }, "funding": [ { @@ -3878,7 +4045,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:39:42+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4192,16 +4359,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -4236,7 +4403,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -4256,20 +4423,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "shasum": "" }, "require": { @@ -4318,7 +4485,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" }, "funding": [ { @@ -4338,20 +4505,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-03-06T13:15:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", "shasum": "" }, "require": { @@ -4393,7 +4560,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -4437,7 +4604,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" }, "funding": [ { @@ -4457,20 +4624,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-03-06T16:33:18+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -4521,7 +4688,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -4541,20 +4708,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "shasum": "" }, "require": { @@ -4565,7 +4732,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -4573,7 +4740,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -4610,7 +4777,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.7" }, "funding": [ { @@ -4630,7 +4797,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-03-05T15:24:09+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5596,16 +5763,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -5657,7 +5824,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -5677,7 +5844,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", @@ -5768,16 +5935,16 @@ }, { "name": "symfony/string", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { @@ -5835,7 +6002,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -5855,20 +6022,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T10:54:30+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "symfony/translation", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "bfde13711f53f549e73b06d27b35a55207528877" + "reference": "1888cf064399868af3784b9e043240f1d89d25ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/bfde13711f53f549e73b06d27b35a55207528877", - "reference": "bfde13711f53f549e73b06d27b35a55207528877", + "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", + "reference": "1888cf064399868af3784b9e043240f1d89d25ce", "shasum": "" }, "require": { @@ -5935,7 +6102,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.4" + "source": "https://github.com/symfony/translation/tree/v7.4.6" }, "funding": [ { @@ -5955,7 +6122,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T10:40:19+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/translation-contracts", @@ -6119,16 +6286,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -6182,7 +6349,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -6202,7 +6369,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6686,16 +6853,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -6706,13 +6873,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -6749,20 +6917,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "laravel/sail", - "version": "v1.53.0", + "version": "v1.55.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "url": "https://api.github.com/repos/laravel/sail/zipball/67dc1b72da4e066a2fb54c1c7582fd2f140ea191", + "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191", "shasum": "" }, "require": { @@ -6812,7 +6980,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-02-06T12:16:02+00:00" + "time": "2026-03-23T15:56:34+00:00" }, { "name": "mockery/mockery", @@ -8668,16 +8836,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", "shasum": "" }, "require": { @@ -8720,7 +8888,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.6" }, "funding": [ { @@ -8740,7 +8908,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "theseer/tokenizer", @@ -8802,5 +8970,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/app.php b/config/app.php old mode 100755 new mode 100644 diff --git a/config/auth.php b/config/auth.php old mode 100755 new mode 100644 diff --git a/config/badges.php b/config/badges.php new file mode 100644 index 00000000..51817475 --- /dev/null +++ b/config/badges.php @@ -0,0 +1,13 @@ + env('BADGE_SCHOOL_NAME', 'Al Rahma Sunday School'), + 'motto' => env('BADGE_MOTTO', 'Learn • Lead • Succeed'), + 'role_label' => env('BADGE_ROLE_LABEL', 'Student'), + 'footer_address' => env('BADGE_FOOTER_ADDRESS', ''), +]; diff --git a/config/cache.php b/config/cache.php old mode 100755 new mode 100644 diff --git a/config/database.php b/config/database.php old mode 100755 new mode 100644 diff --git a/config/docs.php b/config/docs.php new file mode 100644 index 00000000..4238acc0 --- /dev/null +++ b/config/docs.php @@ -0,0 +1,51 @@ + env('DOCS_CLIENT_URL', 'http://localhost:5173'), + + /* + |-------------------------------------------------------------------------- + | Docs hub (CI: `DocsController::index` / docs/index view) + |-------------------------------------------------------------------------- + */ + + 'index' => [ + 'title' => env('DOCS_INDEX_TITLE', 'API documentation'), + 'description' => env('DOCS_INDEX_DESCRIPTION', ''), + ], + + /* + |-------------------------------------------------------------------------- + | Static OpenAPI YAML files (CI: public/docs/*.yaml + View\DocsController::swagger) + |-------------------------------------------------------------------------- + | + | Deploy the same YAML files under `public/docs/` or adjust paths. Dropdown + | URLs are built via ApplicationUrlService::absolutePublicPathUrl in DocsCatalogService. + | The merged JSON is served at GET /api/documentation/swagger.json (route name + | `docs.swagger-json`; see DocsCatalogController::swaggerMergedJson). + | + */ + + 'swagger_specs' => [ + [ + 'name' => 'Administrator API', + 'path' => '/docs/openapi_administrator_controller.yaml', + ], + [ + 'name' => 'Parent API', + 'path' => '/docs/openapi_parent_controller.yaml', + ], + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php old mode 100755 new mode 100644 diff --git a/config/jwt.php b/config/jwt.php old mode 100755 new mode 100644 index 5f817b7d..ba475332 --- a/config/jwt.php +++ b/config/jwt.php @@ -89,7 +89,8 @@ return [ | */ - 'ttl' => (int) env('JWT_TTL', 60), + // CodeIgniter apiLogin uses exp = iat + 86400 (24h). Default below matches that when JWT_TTL is unset. + 'ttl' => (int) env('JWT_TTL', 1440), /* |-------------------------------------------------------------------------- diff --git a/config/logging.php b/config/logging.php old mode 100755 new mode 100644 diff --git a/config/mail.php b/config/mail.php old mode 100755 new mode 100644 diff --git a/config/queue.php b/config/queue.php old mode 100755 new mode 100644 diff --git a/config/services.php b/config/services.php old mode 100755 new mode 100644 diff --git a/config/session.php b/config/session.php old mode 100755 new mode 100644 diff --git a/database/.gitignore b/database/.gitignore old mode 100755 new mode 100644 diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php old mode 100755 new mode 100644 diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 00000000..05fb5d9e --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 00000000..b9c106be --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 00000000..425e7058 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php old mode 100755 new mode 100644 diff --git a/default.php b/default.php new file mode 100644 index 00000000..d7a33ca9 --- /dev/null +++ b/default.php @@ -0,0 +1,195 @@ + + + + Default page + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + +

    You Are All Set to Go!

    +

    All you have to do now is upload your website files and start your journey. Check out how to do that below:

    + + +
    + + \ No newline at end of file diff --git a/envfile.txt b/envfile.txt new file mode 100644 index 00000000..7e663015 --- /dev/null +++ b/envfile.txt @@ -0,0 +1,81 @@ +APP_NAME=Alrahma_API +APP_ENV=production +APP_KEY=base64:RfIZKqgXC9seghl2Jqs2MgLh1X1Z7APRsnhUK8CgWx8= +APP_DEBUG=true +APP_TIMEZONE=America/New_York +APP_URL=https://api.alrahmaisgl.org + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file + +LOG_CHANNEL=stack +LOG_LEVEL=info +LOG_DEPRECATIONS_CHANNEL=null + +# ---------------------------- +# DATABASE +# ---------------------------- +DB_CONNECTION=mysql +DB_HOST=localhost +DB_PORT=3306 +DB_DATABASE=u280815660_api +DB_USERNAME=u280815660_apitest +DB_PASSWORD="uP98sipbT;" + +# ---------------------------- +# SESSION +# ---------------------------- +SESSION_DRIVER=cookie +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +# ---------------------------- +# CACHE & QUEUE +# ---------------------------- +CACHE_STORE=file +QUEUE_CONNECTION=sync + +# ---------------------------- +# REDIS (if needed later) +# ---------------------------- +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +# ---------------------------- +# MAIL (logs only) +# ---------------------------- +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="no-reply@alrahmaisgl.org" +MAIL_FROM_NAME="Alrahma API" + +# ---------------------------- +# AWS STORAGE (optional) +# ---------------------------- +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +# ---------------------------- +# FRONT-END +# ---------------------------- +VITE_APP_NAME="${APP_NAME}" + +# ---------------------------- +# JWT +# ---------------------------- +JWT_SECRET=Uj8rGnYcXMgeRc5qCIn9Wn03tYo1pCsBz1Biou8T9zWtaNxBYi3P4NP5vuFiXHmd +JWT_ALGO=HS256 diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/index.php b/index.php new file mode 100644 index 00000000..cb29c71d --- /dev/null +++ b/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/phpunit.xml b/phpunit.xml index d7032415..d5d52732 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ + diff --git a/resources/css/app.css b/resources/css/app.css old mode 100755 new mode 100644 diff --git a/resources/docs/controllers.md b/resources/docs/controllers.md old mode 100755 new mode 100644 diff --git a/resources/docs/openapi.json b/resources/docs/openapi.json old mode 100755 new mode 100644 index cbf86ad0..12e429f5 --- a/resources/docs/openapi.json +++ b/resources/docs/openapi.json @@ -850,8 +850,8 @@ }, { "name": "BaseApiController", - "class": "App\\Http\\Controllers\\Api\\BaseApiController", - "path": "app/Http/Controllers/Api/BaseApiController.php" + "class": "App\\Http\\Controllers\\Api\\Core\\BaseApiController", + "path": "app/Http/Controllers/Api/Core/BaseApiController.php" }, { "name": "BroadcastEmailController", @@ -865,8 +865,8 @@ }, { "name": "CiRequestAdapter", - "class": "App\\Http\\Controllers\\Api\\CiRequestAdapter", - "path": "app/Http/Controllers/Api/CiRequestAdapter.php" + "class": "App\\Http\\Controllers\\Api\\Core\\CiRequestAdapter", + "path": "app/Http/Controllers/Api/Core/CiRequestAdapter.php" }, { "name": "ClassController", @@ -1114,9 +1114,9 @@ "path": "app/Http/Controllers/Api/QuizController.php" }, { - "name": "RFIDController", - "class": "App\\Http\\Controllers\\Api\\RFIDController", - "path": "app/Http/Controllers/Api/RFIDController.php" + "name": "BadgeScanController", + "class": "App\\Http\\Controllers\\Api\\BadgeScan\\BadgeScanController", + "path": "app/Http/Controllers/Api/BadgeScan/BadgeScanController.php" }, { "name": "RefundController", @@ -1135,13 +1135,13 @@ }, { "name": "RolePermissionController", - "class": "App\\Http\\Controllers\\Api\\RolePermissionController", - "path": "app/Http/Controllers/Api/RolePermissionController.php" + "class": "App\\Http\\Controllers\\Api\\Auth\\RolePermissionController", + "path": "app/Http/Controllers/Api/Auth/RolePermissionController.php" }, { "name": "RoleSwitcherController", - "class": "App\\Http\\Controllers\\Api\\RoleSwitcherController", - "path": "app/Http/Controllers/Api/RoleSwitcherController.php" + "class": "App\\Http\\Controllers\\Api\\Auth\\RoleSwitcherController", + "path": "app/Http/Controllers/Api/Auth/RoleSwitcherController.php" }, { "name": "SchoolCalendarController", @@ -1195,8 +1195,8 @@ }, { "name": "StatsController", - "class": "App\\Http\\Controllers\\Api\\StatsController", - "path": "app/Http/Controllers/Api/StatsController.php" + "class": "App\\Http\\Controllers\\Api\\System\\StatsController", + "path": "app/Http/Controllers/Api/System/StatsController.php" }, { "name": "StudentController", @@ -1240,8 +1240,8 @@ }, { "name": "UserController", - "class": "App\\Http\\Controllers\\Api\\UserController", - "path": "app/Http/Controllers/Api/UserController.php" + "class": "App\\Http\\Controllers\\Api\\Users\\UserController", + "path": "app/Http/Controllers/Api/Users/UserController.php" }, { "name": "WhatsappController", @@ -5220,11 +5220,11 @@ } } }, - "/api/v1/students/{studentId}/rfid": { + "/api/v1/students/{studentId}/badge_scan": { "post": { - "operationId": "updateStudentRfid", + "operationId": "updateStudentBadgeScan", "tags": ["Students"], - "summary": "StudentController::updateRfid", + "summary": "StudentController::updateBadgeScan", "security": [{"bearerAuth": []}], "parameters": [ {"name": "studentId", "in": "path", "required": true, "schema": {"type": "integer"}} diff --git a/resources/js/app.js b/resources/js/app.js old mode 100755 new mode 100644 diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js old mode 100755 new mode 100644 diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/routes/api.php b/routes/api.php old mode 100755 new mode 100644 index b92c0e8c..7ed7156e --- a/routes/api.php +++ b/routes/api.php @@ -3,8 +3,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\Auth\AuthController; -use App\Http\Controllers\DocsController; -use App\Http\Controllers\Api\UserController; +use App\Http\Controllers\Api\Users\UserController; use App\Http\Controllers\Api\Administrator\AdministratorEnrollmentController; use App\Http\Controllers\Api\Administrator\AdministratorAbsenceController; use App\Http\Controllers\Api\Administrator\AdministratorDashboardController; @@ -14,7 +13,6 @@ use App\Http\Controllers\Api\Administrator\EmergencyContactController as Adminis use App\Http\Controllers\Api\Administrator\TeacherClassAssignmentController; use App\Http\Controllers\Api\Badges\BadgeController; use App\Http\Controllers\Api\Students\StudentController; -use App\Http\Controllers\Api\Attendance\AttendanceController; use App\Http\Controllers\Api\Students\StudentController as StudentApiController; use App\Http\Controllers\Api\Finance\InvoiceController; use App\Http\Controllers\Api\Assignment\AssignmentApiController; @@ -77,15 +75,15 @@ use App\Http\Controllers\Api\Finance\PaymentEventChargesController; use App\Http\Controllers\Api\Finance\PaymentManualController; use App\Http\Controllers\Api\Finance\PaypalPaymentController; use App\Http\Controllers\Api\Auth\RegisterController; -use App\Http\Controllers\Api\RolePermissionController; -use App\Http\Controllers\Api\RoleSwitcherController; +use App\Http\Controllers\Api\Auth\RolePermissionController; +use App\Http\Controllers\Api\Auth\RoleSwitcherController; use App\Http\Controllers\Api\Settings\SchoolCalendarController; use App\Http\Controllers\Api\Scores\ScorePredictorController; use App\Http\Controllers\Api\Reports\SlipPrinterController; use App\Http\Controllers\Api\Inventory\SupplierController; use App\Http\Controllers\Api\Inventory\SupplyCategoryController; use App\Http\Controllers\Api\Staff\TeacherController; -use App\Http\Controllers\Api\StatsController; +use App\Http\Controllers\Api\System\StatsController; use App\Http\Controllers\Api\Ui\UiController; use App\Http\Controllers\Api\Support\ContactController as SupportContactController; use App\Http\Controllers\Api\Support\SupportController as ApiSupportController; @@ -113,8 +111,77 @@ use App\Http\Controllers\Api\Attendance\AdminAttendanceApiController; use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController; use App\Http\Controllers\Api\Attendance\TeacherAttendanceApiController; use App\Http\Controllers\Api\Attendance\StaffAttendanceApiController; +use App\Http\Controllers\Api\Utilities\ProofreadController; +use App\Http\Controllers\Api\PrintRequests\PrintRequestsController; +use App\Http\Controllers\Api\BadgeScan\BadgeScanController; +use App\Http\Controllers\Api\Documentation\ApiDocsAdminController; +use App\Http\Controllers\Api\Documentation\DocsCatalogController; +use App\Http\Controllers\Api\Parents\AuthorizedUserInviteController; +use App\Http\Controllers\Api\Public\PublicWinnersController; +use App\Http\Controllers\Api\Staff\TimeOffNotificationController; +use App\Http\Controllers\Api\System\AccessDeniedController; +// CodeIgniter also registers POST /api/login and POST /api/register (public, no /v1). +Route::post('login', [AuthController::class, 'login']); +Route::post('register', [RegisterController::class, 'store']); + +/* +| CodeIgniter POST /api/proofread (LanguageTool proxy; session or Bearer like CI auth filter). +*/ +Route::post('proofread', [ProofreadController::class, 'check'])->middleware('teacher.portal.auth'); + +Route::get('documentation/swagger.json', [DocsCatalogController::class, 'swaggerMergedJson']) + ->name('docs.swagger-json'); + +Route::prefix('documentation')->group(function () { + Route::get('/', [DocsCatalogController::class, 'index'])->name('docs.index'); + Route::get('/swagger', [DocsCatalogController::class, 'swagger'])->name('docs.swagger.catalog'); +}); + +Route::middleware('auth.docs')->group(function () { + Route::get('docs', [ApiDocsAdminController::class, 'index'])->name('api-docs.index'); +}); + +Route::get('docs/public', [ApiDocsAdminController::class, 'public'])->name('api-docs.public'); + +Route::get('access_denied', [AccessDeniedController::class, 'accessDenied'])->name('access_denied'); + +Route::get('timeoff/notify/{token}', [TimeOffNotificationController::class, 'notify']) + ->where('token', '[^/]+') + ->name('api.timeoff.notify'); + +Route::prefix('winners/competitions')->group(function () { + Route::get('/', [PublicWinnersController::class, 'competitionIndex']); + Route::get('{id}', [PublicWinnersController::class, 'competitionShow'])->whereNumber('id'); +}); + +Route::get('confirm_authorized_user', [AuthorizedUserInviteController::class, 'confirm']) + ->name('api.invite.confirm'); +Route::get('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'setPasswordForm']) + ->whereNumber('authorizedUserId') + ->name('api.invite.set-password-form'); +Route::post('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'savePassword']) + ->whereNumber('authorizedUserId') + ->name('api.invite.set-password'); + +/* +| Legacy paths without /v1: POST cannot use 301 reliably; alias to same handlers as v1 (auth:api). +| GET uses 301 to canonical /api/v1/... +*/ +Route::middleware('auth:api')->prefix('attendance-templates')->group(function () { + Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']); + Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']); +}); + +Route::permanentRedirect('attendance-templates', '/api/v1/attendance-templates'); +Route::permanentRedirect('attendance-comment-templates', '/api/v1/attendance-comment-templates'); +Route::permanentRedirect('attendance-comment-templates/list-data', '/api/v1/attendance-comment-templates/list-data'); +Route::permanentRedirect('administrator/attendance-templates', '/api/v1/administrator/attendance-templates'); Route::prefix('v1')->group(function () { + // Parity with CodeIgniter: POST /api/v1/login and POST /api/v1/register (public, no /auth prefix) + Route::post('login', [AuthController::class, 'login']); + Route::post('register', [RegisterController::class, 'store']); + Route::prefix('auth')->group(function () { Route::get('register/captcha', [RegisterController::class, 'captcha']); Route::post('register', [RegisterController::class, 'store']); @@ -138,6 +205,12 @@ Route::prefix('v1')->group(function () { Route::post('contact', [SupportContactController::class, 'send']); + /* + | Badge scan kiosk (public, throttled). Logs: authenticated staff (legacy CI RFIDController). + */ + Route::post('badge_scan/scan', [BadgeScanController::class, 'scan']) + ->middleware('throttle:120,1'); + Route::prefix('frontend')->group(function () { Route::get('/', [FrontendController::class, 'index']); Route::get('facility', [FrontendController::class, 'facility']); @@ -168,6 +241,8 @@ Route::prefix('v1')->group(function () { Route::post('reorder', [NavBuilderController::class, 'reorder']); }); + Route::middleware(['auth:api', 'badge_scan.logs'])->get('badge_scan/logs', [BadgeScanController::class, 'logs']); + Route::middleware('auth:api')->prefix('landing')->group(function () { Route::get('/', [LandingPageController::class, 'index']); Route::get('teacher', [LandingPageController::class, 'teacher']); @@ -178,11 +253,35 @@ Route::prefix('v1')->group(function () { Route::get('guest', [LandingPageController::class, 'guest']); }); + /* + | Print requests (CodeIgniter PrintRequests — teacher/admin JSON + uploads). + */ + Route::middleware('auth:api')->prefix('print-requests')->group(function () { + Route::middleware('print_requests.teacher')->group(function () { + Route::get('teacher', [PrintRequestsController::class, 'teacher']); + Route::post('/', [PrintRequestsController::class, 'store']); + Route::patch('{id}', [PrintRequestsController::class, 'teacherUpdate'])->whereNumber('id'); + Route::delete('{id}', [PrintRequestsController::class, 'destroy'])->whereNumber('id'); + Route::post('hand-copy', [PrintRequestsController::class, 'handCopy']); + Route::post('{id}/copy', [PrintRequestsController::class, 'copy'])->whereNumber('id'); + }); + + Route::middleware('print_requests.admin')->group(function () { + Route::get('admin', [PrintRequestsController::class, 'admin']); + Route::patch('{id}/status', [PrintRequestsController::class, 'adminStatus'])->whereNumber('id'); + }); + + Route::get('{id}/file', [PrintRequestsController::class, 'download'])->whereNumber('id'); + }); + Route::middleware('auth:api')->prefix('info-icon')->group(function () { Route::get('profile', [InfoIconController::class, 'profileIcon']); }); Route::middleware('auth:api')->prefix('administrator')->group(function () { + Route::get('attendance-templates', [AttendanceCommentTemplateController::class, 'bootstrapUrls']) + ->name('api.v1.administrator.attendance-templates.bootstrap'); + Route::get('absence', [AdministratorAbsenceController::class, 'index']); Route::post('absence', [AdministratorAbsenceController::class, 'store']); @@ -237,7 +336,7 @@ Route::prefix('v1')->group(function () { }); }); - Route::prefix('attendance-tracking')->group(function () { + Route::middleware('auth:api')->prefix('attendance-tracking')->group(function () { Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']); Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']); Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']); @@ -249,7 +348,26 @@ Route::prefix('v1')->group(function () { Route::post('/save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']); }); - Route::prefix('assignments')->group(function () { + Route::middleware('auth:api')->prefix('attendance-comment-templates')->group(function () { + Route::get('/', [AttendanceCommentTemplateController::class, 'index'])->name('api.v1.attendance-comment-templates.index'); + Route::get('list-data', [AttendanceCommentTemplateController::class, 'listData']) + ->name('api.v1.attendance-comment-templates.list-data'); + Route::get('{id}', [AttendanceCommentTemplateController::class, 'show']); + Route::post('/', [AttendanceCommentTemplateController::class, 'store']); + Route::put('{id}', [AttendanceCommentTemplateController::class, 'update']); + Route::delete('{id}', [AttendanceCommentTemplateController::class, 'destroy']); + }); + + /* + | CodeIgniter View\AttendanceCommentTemplateController — legacy list/save/delete (auth:api). + */ + Route::middleware('auth:api')->prefix('attendance-templates')->group(function () { + Route::get('/', [AttendanceCommentTemplateController::class, 'listData'])->name('api.v1.attendance-templates.legacy'); + Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']); + Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']); + }); + + Route::middleware('auth:api')->prefix('assignments')->group(function () { Route::get('/', [AssignmentApiController::class, 'index']); Route::post('/', [AssignmentApiController::class, 'store']); Route::get('/class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']); @@ -416,10 +534,11 @@ Route::prefix('v1')->group(function () { Route::get('{studentId}/emergency-contacts', [StudentApiController::class, 'emergencyContacts']); Route::post('{studentId}/emergency-contacts', [StudentApiController::class, 'addEmergencyContact']); Route::patch('{studentId}/emergency-contacts/{contactId}', [StudentApiController::class, 'updateEmergencyContact']); - Route::post('{studentId}/rfid', [StudentApiController::class, 'updateRfid']); + 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}/report-card', [ReportCardsController::class, 'studentReport']); + Route::get('{studentId}/report-card', [ReportCardsController::class, 'studentReport']) + ->name('api.v1.students.report-card'); Route::get('{studentId}/score-card', [StudentApiController::class, 'scoreCard']); }); @@ -444,6 +563,12 @@ Route::prefix('v1')->group(function () { Route::get('attendance-reports', [ParentAttendanceReportApiController::class, 'list']); Route::post('attendance-reports/check-existing', [ParentAttendanceReportApiController::class, 'checkExisting']); Route::patch('attendance-reports/{reportId}', [ParentAttendanceReportApiController::class, 'update']); + + Route::get('authorized-users', [AuthorizedUsersController::class, 'index']); + Route::get('authorized-users/{id}', [AuthorizedUsersController::class, 'show'])->whereNumber('id'); + Route::post('authorized-users', [AuthorizedUsersController::class, 'store']); + Route::patch('authorized-users/{id}', [AuthorizedUsersController::class, 'update'])->whereNumber('id'); + Route::delete('authorized-users/{id}', [AuthorizedUsersController::class, 'destroy'])->whereNumber('id'); }); Route::prefix('users')->group(function () { @@ -587,7 +712,8 @@ Route::prefix('v1')->group(function () { Route::post('batches/assign', [ReimbursementController::class, 'updateBatchAssignment']); Route::post('batches/lock', [ReimbursementController::class, 'lockBatch']); Route::post('batches/admin-files', [ReimbursementController::class, 'uploadBatchAdminFile']); - Route::get('batches/admin-files/{name}/{mode?}', [ReimbursementController::class, 'serveAdminCheckFile']); + Route::get('batches/admin-files/{name}/{mode?}', [ReimbursementController::class, 'serveAdminCheckFile']) + ->name('api.v1.finance.reimbursements.admin-file'); Route::post('batches/email', [ReimbursementController::class, 'sendBatchEmail']); Route::get('export', [ReimbursementController::class, 'export']); Route::get('batches/export', [ReimbursementController::class, 'exportBatch']); @@ -656,17 +782,20 @@ Route::prefix('v1')->group(function () { Route::prefix('paypal')->group(function () { Route::get('redirect', [PaypalPaymentController::class, 'redirect']); Route::post('payments/{paymentId}/create', [PaypalPaymentController::class, 'create']); - Route::post('execute', [PaypalPaymentController::class, 'execute']); - Route::get('cancel', [PaypalPaymentController::class, 'cancel']); + Route::post('execute', [PaypalPaymentController::class, 'execute'])->name('api.v1.finance.paypal.execute'); + Route::get('cancel', [PaypalPaymentController::class, 'cancel'])->name('api.v1.finance.paypal.cancel'); }); }); Route::prefix('files')->group(function () { - Route::get('receipts/{name}', [FilesController::class, 'receipt']); - Route::get('reimbursements/{name}', [FilesController::class, 'reimb']); - Route::get('early-dismissal/{name}', [FilesController::class, 'earlyDismissalSignature']); - Route::get('exams/teacher/{name}', [FilesController::class, 'examDraftTeacher']); - Route::get('exams/final/{name}', [FilesController::class, 'examDraftFinal']); + Route::get('receipts/{name}', [FilesController::class, 'receipt'])->name('api.v1.files.receipt'); + Route::get('reimbursements/{name}', [FilesController::class, 'reimb'])->name('api.v1.files.reimbursement'); + Route::get('early-dismissal/{name}', [FilesController::class, 'earlyDismissalSignature']) + ->name('api.v1.files.early-dismissal'); + Route::get('exams/teacher/{name}', [FilesController::class, 'examDraftTeacher']) + ->name('api.v1.files.exam-draft-teacher'); + Route::get('exams/final/{name}', [FilesController::class, 'examDraftFinal']) + ->name('api.v1.files.exam-draft-final'); }); Route::prefix('reports/slips')->group(function () { @@ -839,11 +968,12 @@ Route::prefix('v1')->group(function () { Route::get('meta', [ClassProgressController::class, 'meta']); Route::get('/', [ClassProgressController::class, 'index']); Route::post('/', [ClassProgressController::class, 'store']); + Route::get('attachments/{attachment}', [ClassProgressController::class, 'downloadAttachment']) + ->name('api.v1.class-progress.attachment'); Route::get('{class_progress}', [ClassProgressController::class, 'show']); Route::patch('{class_progress}', [ClassProgressController::class, 'update']); Route::delete('{class_progress}', [ClassProgressController::class, 'destroy']); Route::get('{class_progress}/attachment', [ClassProgressController::class, 'downloadLegacyAttachment']); - Route::get('attachments/{attachment}', [ClassProgressController::class, 'downloadAttachment']); }); Route::prefix('badges')->group(function () { @@ -854,48 +984,3 @@ Route::prefix('v1')->group(function () { }); }); }); - -Route::prefix('attendance-comment-templates')->group(function () { - Route::get('/', [AttendanceCommentTemplateController::class, 'index']); - Route::get('list-data', [AttendanceCommentTemplateController::class, 'listData']); - Route::get('{id}', [AttendanceCommentTemplateController::class, 'show']); - Route::post('/', [AttendanceCommentTemplateController::class, 'store']); - Route::put('{id}', [AttendanceCommentTemplateController::class, 'update']); - Route::delete('{id}', [AttendanceCommentTemplateController::class, 'destroy']); -}); - -Route::prefix('attendance-tracking')->group(function () { - Route::get('pending-violations', [AttendanceTrackingController::class, 'pendingViolations']); - Route::get('notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']); - Route::get('student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']); - Route::post('record', [AttendanceTrackingController::class, 'record']); - Route::post('send-auto-emails', [AttendanceTrackingController::class, 'sendAutoEmails']); - Route::get('compose', [AttendanceTrackingController::class, 'compose']); - Route::post('send-manual-email', [AttendanceTrackingController::class, 'sendManualEmail']); - Route::get('parents-info', [AttendanceTrackingController::class, 'parentsInfo']); - Route::post('save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']); -}); - -Route::middleware('auth:api')->group(function () { - Route::prefix('assignments')->group(function () { - Route::get('/', [AssignmentApiController::class, 'index']); - Route::post('/', [AssignmentApiController::class, 'store']); - Route::get('class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']); - }); - - Route::prefix('badges')->group(function () { - Route::get('form-data', [BadgeController::class, 'formData']); - Route::post('pdf', [BadgeController::class, 'generatePdf']); - Route::get('print-status', [BadgeController::class, 'printStatus']); - Route::post('log-print', [BadgeController::class, 'logPrint']); - }); - - Route::prefix('class-prep')->group(function () { - Route::get('/', [ClassPreparationController::class, 'index']); - Route::post('mark-printed', [ClassPreparationController::class, 'markPrinted']); - Route::post('adjustments', [ClassPreparationController::class, 'saveAdjustments']); - Route::post('print/{classSectionId}/{schoolYear}', [ClassPreparationController::class, 'print']); - Route::get('sticker-counts', [ClassPreparationController::class, 'stickerCounts']); - Route::get('classes/{classSectionId}/students', [ClassPreparationController::class, 'studentsByClass']); - }); -}); diff --git a/routes/web.php b/routes/web.php old mode 100755 new mode 100644 index 57ebdd58..6ac320d3 --- a/routes/web.php +++ b/routes/web.php @@ -1,21 +1,78 @@ name('docs.home'); -Route::view('/api/docs', 'docs.swagger')->name('docs.swagger'); -Route::get('/dashboard', [DashboardRedirectController::class, 'dashboard'])->name('dashboard.redirect'); +/* +| Cookie session + browser-facing paths (SPA still calls same-origin /login, /logout, …). +| All REST business logic lives under routes/api.php → /api, /api/v1. +*/ -Route::get('/api/documentation/swagger.json', function () { - $path = resource_path('docs/openapi.json'); - abort_unless(is_file($path), 404); - - $spec = app(OpenApiRouteExporter::class)->exportFromFile($path); - - return response()->json($spec, 200, [ - 'Content-Type' => 'application/json', - 'Cache-Control' => 'no-store, max-age=0', +Route::get('/', function () { + return response()->json([ + 'status' => 'ok', + 'service' => config('app.name', 'api'), + 'docs' => url('/docs'), ]); -})->name('docs.swagger-json'); +})->name('api.home'); + +Route::get('docs', function () { + $specUrl = url('/api/documentation/swagger.json'); + + $html = << + + + + + Alrahma API Docs + + + + +
    + + + + +HTML; + + return response($html, 200)->header('Content-Type', 'text/html; charset=UTF-8'); +})->name('docs.ui'); + +Route::middleware('web')->group(function () { + Route::get('login', [AuthSessionController::class, 'loginMask'])->name('login'); + Route::post('user/login', [AuthSessionController::class, 'login'])->name('auth.session.login'); + Route::get('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout'); + Route::get('select-role', [AuthSessionController::class, 'selectRole'])->name('auth.session.select-role'); + Route::post('set-role', [AuthSessionController::class, 'setRole'])->name('auth.session.set-role'); + + Route::get('session/check-timeout', [SessionTimeoutController::class, 'checkTimeout']) + ->name('auth.session.check-timeout'); + Route::get('session/check', [SessionTimeoutController::class, 'checkTimeout']) + ->name('auth.session.check'); + Route::post('session/ping', [SessionTimeoutController::class, 'pingActivity']) + ->name('auth.session.ping'); + Route::get('session/timeout-config', [SessionTimeoutController::class, 'getTimeoutConfig']) + ->name('auth.session.timeout-config'); + + Route::middleware('auth')->get('dashboard', [DashboardRedirectController::class, 'dashboard']) + ->name('dashboard.redirect'); +}); diff --git a/storage/app/.gitignore b/storage/app/.gitignore old mode 100755 new mode 100644 diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore old mode 100755 new mode 100644 diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore old mode 100755 new mode 100644 diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore old mode 100755 new mode 100644 diff --git a/storage/testing/files/sample.exe b/storage/testing/files/sample.exe old mode 100644 new mode 100755 diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php index 1ad372d3..0d9b3f4a 100644 --- a/tests/CreatesApplication.php +++ b/tests/CreatesApplication.php @@ -3,10 +3,11 @@ namespace Tests; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Application; trait CreatesApplication { - public function createApplication() + public function createApplication(): Application { $app = require __DIR__.'/../bootstrap/app.php'; diff --git a/tests/Feature/HealthEndpointTest.php b/tests/Feature/HealthEndpointTest.php new file mode 100644 index 00000000..c9a7b74f --- /dev/null +++ b/tests/Feature/HealthEndpointTest.php @@ -0,0 +1,13 @@ +get('/up')->assertSuccessful(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f4f1f029..2932d4a6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,24 +2,9 @@ namespace Tests; -use Illuminate\Support\Facades\DB; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { use CreatesApplication; - - protected function tearDown(): void - { - try { - $connection = DB::connection(); - $pdo = $connection->getPdo(); - if ($pdo->inTransaction()) { - $connection->rollBack(); - } - } catch (\Throwable $e) { - } - - parent::tearDown(); - } } diff --git a/tests/Unit/ApiDocsServiceTest.php b/tests/Unit/ApiDocsServiceTest.php new file mode 100644 index 00000000..fd77b328 --- /dev/null +++ b/tests/Unit/ApiDocsServiceTest.php @@ -0,0 +1,48 @@ +createMock(ApplicationUrlService::class); + $urls->method('docsSwaggerMergedJsonUrl')->with(false)->willReturn('/api/documentation/swagger.json'); + + return new ApiDocsService($urls); + } + + public function test_public_bootstrap_matches_ci_public_flags(): void + { + $svc = $this->docsService(); + $b = $svc->bootstrap(null, true); + + $this->assertFalse($b['try_it_out_enabled']); + $this->assertFalse($b['persist_authorization']); + $this->assertFalse($b['show_authorize_button']); + $this->assertSame('public', $b['variant']); + $this->assertNull($b['welcome_name']); + } + + public function test_full_bootstrap_enables_try_and_welcome(): void + { + $user = new User([ + 'firstname' => 'A', + 'lastname' => 'B', + 'email' => 'x@y.z', + ]); + $svc = $this->docsService(); + $b = $svc->bootstrap($user, false); + + $this->assertTrue($b['try_it_out_enabled']); + $this->assertTrue($b['persist_authorization']); + $this->assertTrue($b['show_authorize_button']); + $this->assertSame('full', $b['variant']); + $this->assertSame('A B', $b['welcome_name']); + } +} diff --git a/tests/Unit/ApiLoginSecurityServiceTest.php b/tests/Unit/ApiLoginSecurityServiceTest.php new file mode 100644 index 00000000..bdbb89d8 --- /dev/null +++ b/tests/Unit/ApiLoginSecurityServiceTest.php @@ -0,0 +1,23 @@ +rolesToObjectMap(['Teacher', 'Admin']); + + $this->assertTrue($map->Teacher); + $this->assertTrue($map->Admin); + + $payload = json_encode(['roles' => $map]); + $this->assertIsString($payload); + $this->assertStringContainsString('"Teacher":true', $payload); + $this->assertStringContainsString('"Admin":true', $payload); + } +} diff --git a/tests/Unit/AuthSessionServiceTest.php b/tests/Unit/AuthSessionServiceTest.php new file mode 100644 index 00000000..d7dcf328 --- /dev/null +++ b/tests/Unit/AuthSessionServiceTest.php @@ -0,0 +1,37 @@ +createMock(ApplicationUrlService::class); + $urls->method('docsHomeUrl')->willReturn('http://example.org'); + $this->svc = new AuthSessionService($urls); + } + + public function test_sanitize_redirect_relative(): void + { + $this->assertSame('/dashboard', $this->svc->sanitizeRedirectTarget('/dashboard')); + $this->assertSame('/dash?q=1', $this->svc->sanitizeRedirectTarget('/dash?q=1')); + } + + public function test_sanitize_redirect_empty(): void + { + $this->assertNull($this->svc->sanitizeRedirectTarget('')); + $this->assertNull($this->svc->sanitizeRedirectTarget(' ')); + } + + public function test_sanitize_rejects_protocol_relative(): void + { + $this->assertNull($this->svc->sanitizeRedirectTarget('//evil.example/path')); + } +} diff --git a/tools/ci-controller-to-laravel-mapping.csv b/tools/ci-controller-to-laravel-mapping.csv new file mode 100644 index 00000000..0d42051f --- /dev/null +++ b/tools/ci-controller-to-laravel-mapping.csv @@ -0,0 +1,106 @@ +ci_relative_path,ci_basename,laravel_controller_paths_from_repo_root,migration_status,notes +AdminProgressController.php,AdminProgressController,app/Http/Controllers/Api/ClassProgress/ClassProgressController.php,mapped_renamed, +Admin/CompetitionWinnersController.php,CompetitionWinnersController,app/Http/Controllers/Api/Public/PublicWinnersController.php,mapped_renamed, +ApiDocsController.php,ApiDocsController,app/Http/Controllers/Api/Documentation/ApiDocsAdminController.php,mapped_renamed, +AuthController.php,AuthController,app/Http/Controllers/Api/Auth/AuthController.php,mapped_exact, +BaseController.php,BaseController,,framework_base,"CI framework base" +ClassProgressController.php,ClassProgressController,app/Http/Controllers/Api/ClassProgress/ClassProgressController.php,mapped_exact, +DocsController.php,DocsController,app/Http/Controllers/Api/Documentation/DocsCatalogController.php|app/Http/Controllers/Api/Documentation/ApiDocsAdminController.php,mapped_renamed,"Two CI files (root + View); docs split between catalog + admin" +ErrorController.php,ErrorController,app/Http/Controllers/Api/System/AccessDeniedController.php,mapped_renamed, +Home.php,Home,,stub_replaced,"CI welcome_message only; Laravel SPA + API" +InitializeRolesPermissions.php,InitializeRolesPermissions,,setup_script,"Use Laravel seeds/Artisan; not an HTTP controller" +ParentProgressController.php,ParentProgressController,app/Http/Controllers/Api/ClassProgress/ClassProgressController.php,mapped_renamed, +ParentReportCardController.php,ParentReportCardController,app/Http/Controllers/Api/Reports/ReportCardsController.php,mapped_renamed, +PrintRequests.php,PrintRequests,app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php,mapped_renamed,"HTTP JSON API + PrintRequestsPortalService" +ProofreadController.php,ProofreadController,app/Http/Controllers/Api/Utilities/ProofreadController.php,mapped_exact, +TimeOffNotificationController.php,TimeOffNotificationController,app/Http/Controllers/Api/Staff/TimeOffNotificationController.php,mapped_exact, +View/AdministratorController.php,AdministratorController,app/Http/Controllers/Api/Administrator/AdministratorAbsenceController.php|app/Http/Controllers/Api/Administrator/AdministratorDashboardController.php|app/Http/Controllers/Api/Administrator/AdministratorEnrollmentController.php|app/Http/Controllers/Api/Administrator/AdministratorNotificationController.php|app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php|app/Http/Controllers/Api/Administrator/EmergencyContactController.php|app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php,mapped_split,"Large CI controller split across Laravel admin APIs" +View/AssignmentController.php,AssignmentController,app/Http/Controllers/Api/Assignment/AssignmentApiController.php,mapped_renamed, +View/AttendanceCommentTemplateController.php,AttendanceCommentTemplateController,app/Http/Controllers/Api/Attendance/AttendanceCommentTemplateController.php,mapped_exact, +View/AttendanceController.php,AttendanceController,app/Http/Controllers/Api/Attendance/AdminAttendanceApiController.php|app/Http/Controllers/Api/Attendance/StaffAttendanceApiController.php|app/Http/Controllers/Api/Attendance/TeacherAttendanceApiController.php,mapped_split,"Attendance UI split by role in Laravel API" +View/AttendanceTrackingController.php,AttendanceTrackingController,app/Http/Controllers/Api/AttendanceTracking/AttendanceTrackingController.php,mapped_exact, +View/AuthorizedUsersController.php,AuthorizedUsersController,app/Http/Controllers/Api/Parents/AuthorizedUsersController.php,mapped_exact, +View/BadgesController.php,BadgesController,app/Http/Controllers/Api/Badges/BadgeController.php,mapped_renamed, +View/BroadcastEmailController.php,BroadcastEmailController,app/Http/Controllers/Api/Email/BroadcastEmailController.php,mapped_exact, +View/ClassController.php,ClassController,app/Http/Controllers/Api/Classes/ClassController.php,mapped_exact, +View/ClassPrepController.php,ClassPrepController,app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php,mapped_renamed, +View/ClassPreparationController.php,ClassPreparationController,app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php,mapped_exact, +View/CommunicationController.php,CommunicationController,app/Http/Controllers/Api/Communication/CommunicationController.php,mapped_exact, +View/CompetitionScoresController.php,CompetitionScoresController,app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php,mapped_exact, +View/ConfigurationController.php,ConfigurationController,app/Http/Controllers/Api/Settings/ConfigurationAdminController.php,mapped_renamed, +View/ContactController.php,ContactController,app/Http/Controllers/Api/Support/ContactController.php,mapped_exact, +View/DashboardRedirectController.php,DashboardRedirectController,app/Http/Controllers/Api/System/DashboardRedirectController.php,mapped_exact, +View/DiscountController.php,DiscountController,app/Http/Controllers/Api/Discounts/DiscountController.php,mapped_exact, +View/DocsController.php,DocsController,app/Http/Controllers/Api/Documentation/DocsCatalogController.php|app/Http/Controllers/Api/Documentation/ApiDocsAdminController.php,mapped_renamed,"Two CI files (root + View); docs split between catalog + admin" +View/EmailController.php,EmailController,app/Http/Controllers/Api/Email/EmailController.php,mapped_exact, +View/EmailExtractorController.php,EmailExtractorController,app/Http/Controllers/Api/Email/EmailExtractorController.php,mapped_exact, +View/EmergencyContactController.php,EmergencyContactController,app/Http/Controllers/Api/Administrator/EmergencyContactController.php,mapped_exact, +View/EventController.php,EventController,app/Http/Controllers/Api/Settings/EventController.php,mapped_exact, +View/ExamDraftController.php,ExamDraftController,app/Http/Controllers/Api/Exams/ExamDraftController.php,mapped_exact, +View/ExpenseController.php,ExpenseController,app/Http/Controllers/Api/Expenses/ExpenseController.php,mapped_exact, +View/ExtraChargesController.php,ExtraChargesController,app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php,mapped_exact, +View/FamilyAdminController.php,FamilyAdminController,app/Http/Controllers/Api/Family/FamilyAdminController.php,mapped_exact, +View/FamilyController.php,FamilyController,app/Http/Controllers/Api/Family/FamilyController.php,mapped_exact, +View/FilesController.php,FilesController,app/Http/Controllers/Api/Reports/FilesController.php,mapped_exact, +View/FinalController.php,FinalController,app/Http/Controllers/Api/Scores/FinalController.php,mapped_exact, +View/FinancialController.php,FinancialController,app/Http/Controllers/Api/Finance/FinancialController.php,mapped_exact, +View/FlagController.php,FlagController,app/Http/Controllers/Api/Incidents/IncidentController.php,mapped_renamed, +View/FrontendController.php,FrontendController,app/Http/Controllers/Api/Frontend/FrontendController.php,mapped_exact, +View/GradingController.php,GradingController,app/Http/Controllers/Api/Grading/GradingController.php,mapped_exact, +View/HealthController.php,HealthController,app/Http/Controllers/Api/System/HealthController.php,mapped_exact, +View/HomeworkController.php,HomeworkController,app/Http/Controllers/Api/Scores/HomeworkController.php,mapped_exact, +View/HomeworkTrackingController.php,HomeworkTrackingController,app/Http/Controllers/Api/Grading/HomeworkTrackingController.php,mapped_exact, +View/InfoIconController.php,InfoIconController,app/Http/Controllers/Api/Frontend/InfoIconController.php,mapped_exact, +View/InventoryController.php,InventoryController,app/Http/Controllers/Api/Inventory/InventoryController.php,mapped_exact, +View/InvoiceController.php,InvoiceController,app/Http/Controllers/Api/Finance/InvoiceController.php,mapped_exact, +View/IpBanController.php,IpBanController,app/Http/Controllers/Api/Auth/IpBanController.php,mapped_exact, +View/LandingPageController.php,LandingPageController,app/Http/Controllers/Api/Frontend/LandingPageController.php,mapped_exact, +View/LateSlipLogsController.php,LateSlipLogsController,app/Http/Controllers/Api/Attendance/LateSlipLogsController.php,mapped_exact, +View/MessagesController.php,MessagesController,app/Http/Controllers/Api/Messaging/MessagesController.php,mapped_exact, +View/MidtermController.php,MidtermController,app/Http/Controllers/Api/Scores/MidtermController.php,mapped_exact, +View/NavBuilderController.php,NavBuilderController,app/Http/Controllers/Api/System/NavBuilderController.php,mapped_exact, +View/NotificationsController.php,NotificationsController,app/Http/Controllers/Api/Notifications/NotificationController.php,mapped_renamed, +View/PageController.php,PageController,app/Http/Controllers/Api/Frontend/PageController.php,mapped_exact, +View/ParentAttendanceReportController.php,ParentAttendanceReportController,app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php,mapped_exact, +View/ParentController.php,ParentController,app/Http/Controllers/Api/Parents/ParentController.php,mapped_exact, +View/ParticipationController.php,ParticipationController,app/Http/Controllers/Api/Scores/ParticipationController.php,mapped_exact, +View/PaymentController.php,PaymentController,app/Http/Controllers/Api/Finance/PaymentController.php,mapped_exact, +View/PaymentNotificationController.php,PaymentNotificationController,app/Http/Controllers/Api/Finance/PaymentNotificationController.php,mapped_exact, +View/PaymentTransactionController.php,PaymentTransactionController,app/Http/Controllers/Api/Finance/PaymentTransactionController.php,mapped_exact, +View/PaypalTransactionsController.php,PaypalTransactionsController,app/Http/Controllers/Api/Finance/PaypalTransactionsController.php,mapped_exact, +View/PolicyController.php,PolicyController,app/Http/Controllers/Api/Settings/PolicyController.php,mapped_exact, +View/PreferencesController.php,PreferencesController,app/Http/Controllers/Api/Settings/PreferencesController.php,mapped_exact, +View/PrintRequests.php,PrintRequests,app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php,mapped_renamed,"HTTP JSON API + PrintRequestsPortalService" +View/PrintablesBaseController.php,PrintablesBaseController,,framework_base,"Abstract printable base (CI)" +View/ProjectController.php,ProjectController,app/Http/Controllers/Api/Scores/ProjectController.php,mapped_exact, +View/PurchaseOrderController.php,PurchaseOrderController,app/Http/Controllers/Api/Finance/PurchaseOrderController.php,mapped_exact, +View/QuizController.php,QuizController,app/Http/Controllers/Api/Scores/QuizController.php,mapped_exact, +View/RFIDController.php,RFIDController,app/Http/Controllers/Api/BadgeScan/BadgeScanController.php|app/Http/Controllers/Api/Students/StudentController.php,mapped_split,"Scan + logs: BadgeScanController; assign student badge: StudentController.updateBadgeScan" +View/RefundController.php,RefundController,app/Http/Controllers/Api/Finance/RefundController.php,mapped_exact, +View/RegisterController.php,RegisterController,app/Http/Controllers/Api/Auth/RegisterController.php,mapped_exact, +View/ReimbursementController.php,ReimbursementController,app/Http/Controllers/Api/Finance/ReimbursementController.php,mapped_exact, +View/ReportCardsController.php,ReportCardsController,app/Http/Controllers/Api/Reports/ReportCardsController.php,mapped_exact, +View/RolePermissionController.php,RolePermissionController,app/Http/Controllers/Api/Auth/RolePermissionController.php,mapped_exact, +View/RoleSwitcherController.php,RoleSwitcherController,app/Http/Controllers/Api/Auth/RoleSwitcherController.php,mapped_exact, +View/SchoolCalendarController.php,SchoolCalendarController,app/Http/Controllers/Api/Settings/SchoolCalendarController.php,mapped_exact, +View/ScoreCommentController.php,ScoreCommentController,app/Http/Controllers/Api/Scores/ScoreCommentController.php,mapped_exact, +View/ScoreController.php,ScoreController,app/Http/Controllers/Api/Scores/ScoreController.php,mapped_exact, +View/ScorePredictor.php,ScorePredictor,app/Http/Controllers/Api/Scores/ScorePredictorController.php,mapped_renamed, +View/SendSMSController.php,SendSMSController,app/Http/Controllers/Api/Messaging/MessagesController.php,mapped_renamed, +View/SessionTimeoutController.php,SessionTimeoutController,app/Http/Controllers/Api/Auth/SessionTimeoutController.php,mapped_exact, +View/SettingsController.php,SettingsController,app/Http/Controllers/Api/Settings/SettingsController.php,mapped_exact, +View/SlipPrinterController.php,SlipPrinterController,app/Http/Controllers/Api/Reports/SlipPrinterController.php,mapped_exact, +View/StaffController.php,StaffController,app/Http/Controllers/Api/Staff/StaffController.php,mapped_exact, +View/StatsController.php,StatsController,app/Http/Controllers/Api/System/StatsController.php,mapped_exact, +View/StickersController.php,StickersController,app/Http/Controllers/Api/Reports/StickersController.php,mapped_exact, +View/StudentController.php,StudentController,app/Http/Controllers/Api/Students/StudentController.php,mapped_exact, +View/SubjectCurriculumController.php,SubjectCurriculumController,app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php,mapped_exact, +View/SupplierController.php,SupplierController,app/Http/Controllers/Api/Inventory/SupplierController.php,mapped_exact, +View/SupplyCategoryController.php,SupplyCategoryController,app/Http/Controllers/Api/Inventory/SupplyCategoryController.php,mapped_exact, +View/SupportController.php,SupportController,app/Http/Controllers/Api/Support/SupportController.php,mapped_exact, +View/TeacherController.php,TeacherController,app/Http/Controllers/Api/Staff/TeacherController.php,mapped_exact, +View/TestDBController.php,TestDBController,app/Http/Controllers/Api/System/DatabaseHealthController.php|app/Http/Controllers/Api/System/HealthController.php,mapped_renamed, +View/UiController.php,UiController,app/Http/Controllers/Api/Ui/UiController.php,mapped_exact, +View/UserController.php,UserController,app/Http/Controllers/Api/Users/UserController.php,mapped_exact, +View/WhatsappController.php,WhatsappController,app/Http/Controllers/Api/Messaging/WhatsappController.php,mapped_exact, +WinnersController.php,WinnersController,app/Http/Controllers/Api/Public/PublicWinnersController.php,mapped_renamed, diff --git a/tools/generate-ci-controller-mapping.php b/tools/generate-ci-controller-mapping.php new file mode 100644 index 00000000..3633d18b --- /dev/null +++ b/tools/generate-ci-controller-mapping.php @@ -0,0 +1,264 @@ +#!/usr/bin/env php + */ +$explicit = [ + 'AdminProgressController' => ['paths' => ['Api/ClassProgress/ClassProgressController.php'], 'notes' => ''], + 'ParentProgressController' => ['paths' => ['Api/ClassProgress/ClassProgressController.php'], 'notes' => ''], + 'ParentReportCardController' => ['paths' => ['Api/Reports/ReportCardsController.php'], 'notes' => ''], + 'ApiDocsController' => ['paths' => ['Api/Documentation/ApiDocsAdminController.php'], 'notes' => ''], + 'DocsController' => [ + 'paths' => ['Api/Documentation/DocsCatalogController.php', 'Api/Documentation/ApiDocsAdminController.php'], + 'notes' => 'Two CI files (root + View); docs split between catalog + admin', + ], + 'ConfigurationController' => ['paths' => ['Api/Settings/ConfigurationAdminController.php'], 'notes' => ''], + 'ClassPrepController' => ['paths' => ['Api/ClassPreparation/ClassPreparationController.php'], 'notes' => ''], + 'BadgesController' => ['paths' => ['Api/Badges/BadgeController.php'], 'notes' => ''], + 'NotificationsController' => ['paths' => ['Api/Notifications/NotificationController.php'], 'notes' => ''], + 'FlagController' => ['paths' => ['Api/Incidents/IncidentController.php'], 'notes' => ''], + 'SendSMSController' => ['paths' => ['Api/Messaging/MessagesController.php'], 'notes' => ''], + 'TestDBController' => [ + 'paths' => ['Api/System/DatabaseHealthController.php', 'Api/System/HealthController.php'], + 'notes' => '', + ], + 'ErrorController' => ['paths' => ['Api/System/AccessDeniedController.php'], 'notes' => ''], + 'ScorePredictor' => ['paths' => ['Api/Scores/ScorePredictorController.php'], 'notes' => ''], + 'CompetitionWinnersController' => ['paths' => ['Api/Public/PublicWinnersController.php'], 'notes' => ''], + 'WinnersController' => ['paths' => ['Api/Public/PublicWinnersController.php'], 'notes' => ''], + 'AssignmentController' => ['paths' => ['Api/Assignment/AssignmentApiController.php'], 'notes' => ''], + 'Home' => ['paths' => [], 'notes' => 'CI welcome_message only; Laravel SPA + API'], + 'BaseController' => ['paths' => [], 'notes' => 'CI framework base'], + 'PrintablesBaseController' => ['paths' => [], 'notes' => 'Abstract printable base (CI)'], + 'InitializeRolesPermissions' => ['paths' => [], 'notes' => 'Use Laravel seeds/Artisan; not an HTTP controller'], + 'PrintRequests' => [ + 'paths' => ['Api/PrintRequests/PrintRequestsController.php'], + 'notes' => 'HTTP JSON API + PrintRequestsPortalService', + ], + 'RFIDController' => [ + 'paths' => [ + 'Api/BadgeScan/BadgeScanController.php', + 'Api/Students/StudentController.php', + ], + 'notes' => 'Scan + logs: BadgeScanController; assign student badge: StudentController.updateBadgeScan', + ], + 'AdministratorController' => [ + 'paths' => [ + 'Api/Administrator/AdministratorAbsenceController.php', + 'Api/Administrator/AdministratorDashboardController.php', + 'Api/Administrator/AdministratorEnrollmentController.php', + 'Api/Administrator/AdministratorNotificationController.php', + 'Api/Administrator/AdministratorTeacherSubmissionController.php', + 'Api/Administrator/EmergencyContactController.php', + 'Api/Administrator/TeacherClassAssignmentController.php', + ], + 'notes' => 'Large CI controller split across Laravel admin APIs', + ], + 'AttendanceController' => [ + 'paths' => [ + 'Api/Attendance/AdminAttendanceApiController.php', + 'Api/Attendance/StaffAttendanceApiController.php', + 'Api/Attendance/TeacherAttendanceApiController.php', + ], + 'notes' => 'Attendance UI split by role in Laravel API', + ], +]; + +/** @return array basename => list of relative paths under Http/Controllers */ +function indexLaravelControllers(string $controllersDir): array +{ + $map = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($controllersDir, FilesystemIterator::SKIP_DOTS) + ); + $prefixLen = strlen($controllersDir) + 1; + + foreach ($iterator as $fileInfo) { + /** @var SplFileInfo $fileInfo */ + if (! $fileInfo->isFile() || strcasecmp($fileInfo->getExtension(), 'php') !== 0) { + continue; + } + if ($fileInfo->getFilename() === 'Controller.php') { + continue; + } + $full = $fileInfo->getPathname(); + $rel = str_replace('\\', '/', substr($full, $prefixLen)); + $base = $fileInfo->getBasename('.php'); + if (! isset($map[$base])) { + $map[$base] = []; + } + $map[$base][] = $rel; + } + + return $map; +} + +/** @param string[] $paths */ +function joinRepoPaths(array $paths): string +{ + $prefixed = array_map( + static fn (string $p): string => 'app/Http/Controllers/'.$p, + array_filter($paths) + ); + + return implode('|', $prefixed); +} + +function resolvePaths(string $basename, array $explicit, array $lvFiles): array +{ + if (isset($explicit[$basename])) { + return $explicit[$basename]; + } + if (isset($lvFiles[$basename])) { + return ['paths' => $lvFiles[$basename], 'notes' => '']; + } + + return ['paths' => [], 'notes' => 'NOT FOUND IN Laravel app/Http/Controllers']; +} + +function migrationStatus(string $basename, array $resolved, array $explicit, array $lvFiles): string +{ + if (isset($explicit[$basename])) { + $e = $explicit[$basename]; + if ($basename === 'AdministratorController') { + return 'mapped_split'; + } + if ($basename === 'AttendanceController') { + return 'mapped_split'; + } + if ($basename === 'RFIDController') { + return 'mapped_split'; + } + if ($basename === 'Home') { + return 'stub_replaced'; + } + if (in_array($basename, ['BaseController', 'PrintablesBaseController'], true)) { + return 'framework_base'; + } + if ($basename === 'InitializeRolesPermissions') { + return 'setup_script'; + } + if ($e['paths'] === []) { + return 'no_laravel_controller'; + } + if ($basename === 'DocsController') { + return 'mapped_renamed'; + } + $renamed = [ + 'AdminProgressController', 'ParentProgressController', 'ParentReportCardController', 'ApiDocsController', + 'ConfigurationController', 'ClassPrepController', 'BadgesController', 'NotificationsController', + 'FlagController', 'SendSMSController', 'TestDBController', 'ErrorController', 'ScorePredictor', + 'CompetitionWinnersController', 'WinnersController', 'AssignmentController', 'PrintRequests', + ]; + if (in_array($basename, $renamed, true)) { + return 'mapped_renamed'; + } + + return 'mapped_explicit'; + } + + if (isset($lvFiles[$basename])) { + return count($lvFiles[$basename]) > 1 ? 'ambiguous_duplicate_in_laravel' : 'mapped_exact'; + } + + return 'unmapped'; +} + +$lvFiles = indexLaravelControllers($lvControllersDir); + +$ciFiles = []; +$ciIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($ciRoot, FilesystemIterator::SKIP_DOTS) +); +$ciPrefixLen = strlen($ciRoot) + 1; + +foreach ($ciIterator as $fileInfo) { + if (! $fileInfo->isFile() || strcasecmp($fileInfo->getExtension(), 'php') !== 0) { + continue; + } + $full = $fileInfo->getPathname(); + $rel = str_replace('\\', '/', substr($full, $ciPrefixLen)); + $ciFiles[$full] = $rel; +} +ksort($ciFiles); + +$rows = []; +foreach ($ciFiles as $full => $relPath) { + $basename = basename($full, '.php'); + $resolved = resolvePaths($basename, $explicit, $lvFiles); + $pathStr = joinRepoPaths($resolved['paths']); + $notes = $resolved['notes']; + $status = migrationStatus($basename, $resolved, $explicit, $lvFiles); + + if ($status === 'unmapped') { + $pathStr = ''; + if ($notes === '') { + $notes = 'NOT FOUND IN Laravel app/Http/Controllers'; + } + } + + $rows[] = [$relPath, $basename, $pathStr, $status, $notes]; +} + +$dir = dirname($outputPath); +if (! is_dir($dir)) { + mkdir($dir, 0775, true); +} + +$fh = fopen($outputPath, 'wb'); +if ($fh === false) { + fwrite(STDERR, "Cannot write: {$outputPath}\n"); + exit(1); +} + +$headers = ['ci_relative_path', 'ci_basename', 'laravel_controller_paths_from_repo_root', 'migration_status', 'notes']; +fputcsv($fh, $headers); +foreach ($rows as $row) { + fputcsv($fh, $row); +} +fclose($fh); + +fwrite(STDOUT, "Wrote ".count($rows)." rows to {$outputPath}\n"); diff --git a/tools/normalize_model_timestamps_indent.mjs b/tools/normalize_model_timestamps_indent.mjs new file mode 100644 index 00000000..68fa8318 --- /dev/null +++ b/tools/normalize_model_timestamps_indent.mjs @@ -0,0 +1,24 @@ +/** + * One-off / utility: normalize `public $timestamps` to 4-space class-body indent. + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const modelsDir = path.join(__dirname, "..", "app", "Models"); + +let changed = 0; +for (const f of fs.readdirSync(modelsDir).filter((x) => x.endsWith(".php"))) { + const fp = path.join(modelsDir, f); + let c = fs.readFileSync(fp, "utf8"); + const n = c.replace( + /^(\s+)public \$timestamps = (.+)$/gm, + (_, __, rest) => ` public $timestamps = ${rest}` + ); + if (n !== c) { + fs.writeFileSync(fp, n); + changed++; + } +} +console.log(`normalize_model_timestamps_indent: updated ${changed} file(s)`); diff --git a/tools/sync_fillable_from_codeigniter.mjs b/tools/sync_fillable_from_codeigniter.mjs new file mode 100644 index 00000000..f025d309 --- /dev/null +++ b/tools/sync_fillable_from_codeigniter.mjs @@ -0,0 +1,169 @@ +/** + * Sync Laravel $fillable + $timestamps from CodeIgniter *Model.php (sibling app_codeigniter). + * Run: node tools/sync_fillable_from_codeigniter.mjs + */ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const laravelRoot = join(__dirname, ".."); +const workspaceRoot = join(laravelRoot, ".."); +const ciModelsDir = join(workspaceRoot, "app_codeigniter", "app", "Models"); +const laravelModelsDir = join(laravelRoot, "app", "Models"); + +const manualCiToLaravel = new Map([ + ["SettingsModel.php", "Setting.php"], +]); + +/** CI models that must not sync (legacy duplicate of another model). */ +const skipCiBasenames = new Set([ + "FlagModel.php", // table `flag`; Laravel uses CurrentFlagModel → current_flag +]); + +const skipLaravel = new Set([ + "Teacher.php", + "Communication.php", + "EventCharge.php", + "ClassModel.php", + "Calendar.php", +]); + +function ciFileToLaravel(ciBasename) { + if (manualCiToLaravel.has(ciBasename)) return manualCiToLaravel.get(ciBasename); + const m = ciBasename.match(/^(.+)Model\.php$/); + if (!m) return null; + const base = m[1]; + if (base === "Parent" || base === "Class") return `${base}Model.php`; + return `${base}.php`; +} + +/** Find array after `protected $allowedFields =` (flexible spacing). */ +function extractAllowedFieldsArrayLiteral(src) { + const m = src.match(/protected\s+\$allowedFields\s*=\s*/); + if (!m || m.index === undefined) return null; + let pos = m.index + m[0].length; + while (pos < src.length && /\s/.test(src[pos])) pos++; + if (pos >= src.length || src[pos] !== "[") return null; + const start = pos; + let depth = 0; + for (let i = pos; i < src.length; i++) { + const c = src[i]; + if (c === "[") depth++; + else if (c === "]") { + depth--; + if (depth === 0) return src.slice(start, i + 1); + } + } + return null; +} + +function parseQuotedStrings(arrayLiteral) { + const out = []; + const re = /'((?:\\'|[^'])*)'/g; + let m; + while ((m = re.exec(arrayLiteral))) out.push(m[1].replace(/\\'/g, "'")); + return out; +} + +function renderFillable(items) { + const lines = items.map((it) => { + const escaped = String(it).replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + return ` '${escaped}',`; + }); + return `[\n${lines.join("\n")}\n ]`; +} + +function replaceFillable(lv, inner) { + const re = /protected\s+\$fillable\s*=\s*\[[\s\S]*?\];/m; + if (!re.test(lv)) return null; + return lv.replace(re, `protected $fillable = ${inner};`); +} + +function replaceTimestamps(lv, useTs) { + const line = useTs ? " public $timestamps = true;" : " public $timestamps = false;"; + // Normalize any leading whitespace so re-sync does not duplicate lines or pile up indent. + if (/^\s*public\s+\$timestamps\s*=\s*(true|false)\s*;/m.test(lv)) + return lv.replace(/^\s*public\s+\$timestamps\s*=\s*(true|false)\s*;/m, line); + return lv.replace(/(class\s+\w+[^\n]*\n\{)/, `$1\n${line}\n`); +} + +let updated = 0, + skipped = 0; +const missing = []; + +const files = await readdir(ciModelsDir).catch(() => []); +for (const ciBasename of files) { + if (!ciBasename.endsWith("Model.php")) continue; + if (skipCiBasenames.has(ciBasename)) { + console.error(`Skip CI (legacy): ${ciBasename}`); + skipped++; + continue; + } + const laravelBasename = ciFileToLaravel(ciBasename); + if (!laravelBasename) continue; + if (skipLaravel.has(laravelBasename)) { + console.error(`Skip (alias/base): ${laravelBasename}`); + skipped++; + continue; + } + + const lvPath = join(laravelModelsDir, laravelBasename); + try { + await readFile(lvPath, "utf8"); + } catch { + missing.push(`${laravelBasename} (from ${ciBasename})`); + continue; + } + + const ciSrc = await readFile(join(ciModelsDir, ciBasename), "utf8"); + let lvSrc = await readFile(lvPath, "utf8"); + + let useTs = true; + const tm = ciSrc.match(/protected\s+\$useTimestamps\s*=\s*(true|false)\s*;/); + if (tm) useTs = tm[1] === "true"; + + const allowedBody = extractAllowedFieldsArrayLiteral(ciSrc); + if (!allowedBody) { + console.error(`No allowedFields in ${ciBasename}`); + skipped++; + continue; + } + + const fields = parseQuotedStrings(allowedBody); + if (!fields.length) { + console.error(`Empty allowedFields in ${ciBasename}`); + skipped++; + continue; + } + + if (!/protected\s+\$fillable\s*=\s*\[/.test(lvSrc)) { + console.error(`No $fillable in ${laravelBasename}`); + skipped++; + continue; + } + + const rendered = renderFillable(fields); + let next = replaceFillable(lvSrc, rendered); + if (next == null) { + skipped++; + continue; + } + next = replaceTimestamps(next, useTs); + + if (next === lvSrc) { + console.error(`Unchanged: ${laravelBasename}`); + skipped++; + continue; + } + + await writeFile(lvPath, next, "utf8"); + console.log(`Updated ${laravelBasename}`); + updated++; +} + +console.log(`\nDone. Updated: ${updated}, skipped: ${skipped}`); +if (missing.length) { + console.error(`Missing (${missing.length}): ${missing.slice(0, 25).join(", ")}${missing.length > 25 ? "…" : ""}`); + process.exitCode = 2; +} diff --git a/tools/sync_fillable_from_codeigniter.php b/tools/sync_fillable_from_codeigniter.php new file mode 100644 index 00000000..28380c5d --- /dev/null +++ b/tools/sync_fillable_from_codeigniter.php @@ -0,0 +1,231 @@ +#!/usr/bin/env php + Laravel model basename */ +$manualCiToLaravel = [ + 'FlagModel.php' => 'CurrentFlag.php', +]; + +/** Laravel files skipped (aliases / inheritance) */ +$skipLaravelBasenames = [ + 'Teacher.php', + 'Communication.php', + 'EventCharge.php', + 'ClassModel.php', + 'Calendar.php', +]; + +function ciFileToLaravelFile(string $ciBasename, array $manual): ?string +{ + if (isset($manual[$ciBasename])) { + return $manual[$ciBasename]; + } + if (! preg_match('/^(.+)Model\.php$/', $ciBasename, $m)) { + return null; + } + $base = $m[1]; + if ($base === 'Parent' || $base === 'Class') { + return $base.'Model.php'; + } + + return $base.'.php'; +} + +function extractFirstBracketArray(string $src, string $needle): ?string +{ + $pos = strpos($src, $needle); + if ($pos === false) { + return null; + } + $pos += strlen($needle); + $len = strlen($src); + while ($pos < $len && ctype_space($src[$pos])) { + $pos++; + } + if ($pos >= $len || $src[$pos] !== '[') { + return null; + } + $start = $pos; + $depth = 0; + for ($i = $pos; $i < $len; $i++) { + $c = $src[$i]; + if ($c === '[') { + $depth++; + } elseif ($c === ']') { + $depth--; + if ($depth === 0) { + return substr($src, $start, $i - $start + 1); + } + } + } + + return null; +} + +/** @return list */ +function parseQuotedStringsFromArrayLiteral(string $arrayLiteral): array +{ + $out = []; + if (! preg_match_all("/'((?:\\\\'|[^'])*)'/", $arrayLiteral, $m)) { + return $out; + } + foreach ($m[1] as $raw) { + $out[] = str_replace("\\'", "'", $raw); + } + + return $out; +} + +/** Render inner array only: [\n 'a',\n ] */ +function renderFillableArray(array $items): string +{ + $lines = []; + foreach ($items as $item) { + $escaped = addcslashes($item, "\\'"); + $lines[] = " '".$escaped."',"; + } + + return "[\n".implode("\n", $lines)."\n ]"; +} + +function replaceFillableBlock(string $laravel, string $newArrayLiteral): string +{ + $re = '/protected\s+\$fillable\s*=\s*\[[\s\S]*?\];/m'; + if (! preg_match($re, $laravel)) { + return $laravel; + } + + return preg_replace($re, 'protected $fillable = '.$newArrayLiteral.';', $laravel, 1); +} + +function replaceTimestampsLine(string $laravel, bool $useTimestamps): string +{ + $line = $useTimestamps + ? ' public $timestamps = true;' + : ' public $timestamps = false;'; + + if (preg_match('/public\s+\$timestamps\s*=\s*(true|false)\s*;/', $laravel)) { + return preg_replace( + '/public\s+\$timestamps\s*=\s*(true|false)\s*;/', + $line, + $laravel, + 1 + ); + } + + // Insert after class opening brace line (first line after "class X extends Y") + return preg_replace( + '/(class\s+\w+[^\n]*\n\{[\t ]*\n)/', + '$1'.$line.NL.NL, + $laravel, + 1 + ) ?: preg_replace('/(class\s+\w+[^\n]*\n\{)/', '$1'.NL.$line, $laravel, 1); +} + +$updated = 0; +$skipped = 0; +$missing = []; + +foreach (glob($ciModelsDir.DIRECTORY_SEPARATOR.'*Model.php') ?: [] as $ciPath) { + $ciBasename = basename($ciPath); + $laravelBasename = ciFileToLaravelFile($ciBasename, $manualCiToLaravel); + if ($laravelBasename === null) { + continue; + } + if (in_array($laravelBasename, $skipLaravelBasenames, true)) { + fwrite(STDERR, "Skip (alias/base): {$laravelBasename}".NL); + $skipped++; + + continue; + } + + $lvPath = $laravelModelsDir.DIRECTORY_SEPARATOR.$laravelBasename; + if (! is_file($lvPath)) { + $missing[] = $laravelBasename.' (from '.$ciBasename.')'; + + continue; + } + + $ciSrc = (string) file_get_contents($ciPath); + $lvSrc = (string) file_get_contents($lvPath); + + $useTs = true; + if (preg_match('/protected\s+\$useTimestamps\s*=\s*(true|false)\s*;/', $ciSrc, $tm)) { + $useTs = $tm[1] === 'true'; + } + + $allowedBody = extractFirstBracketArray($ciSrc, 'protected $allowedFields = '); + if ($allowedBody === null) { + fwrite(STDERR, "No allowedFields in {$ciBasename}".NL); + $skipped++; + + continue; + } + + $fields = parseQuotedStringsFromArrayLiteral($allowedBody); + if ($fields === []) { + fwrite(STDERR, "Empty allowedFields in {$ciBasename}".NL); + $skipped++; + + continue; + } + + if (! preg_match('/protected\s+\$fillable\s*=\s*\[/', $lvSrc)) { + fwrite(STDERR, "No \$fillable array in {$laravelBasename} — add manually".NL); + $skipped++; + + continue; + } + + $rendered = renderFillableArray($fields); + $newLv = replaceFillableBlock($lvSrc, $rendered); + $newLv = replaceTimestampsLine($newLv, $useTs); + + if ($newLv === $lvSrc) { + fwrite(STDERR, "Unchanged: {$laravelBasename}".NL); + $skipped++; + + continue; + } + + file_put_contents($lvPath, $newLv); + fwrite(STDOUT, "Updated {$laravelBasename}".NL); + $updated++; +} + +fwrite(STDOUT, NL.'Done. Updated: '.$updated.', skipped: '.$skipped.NL); +if ($missing !== []) { + fwrite(STDERR, 'Missing Laravel files ('.count($missing).'): '.implode(', ', array_slice($missing, 0, 20)).(count($missing) > 20 ? '…' : '').NL); + + exit(2); +}