fix api security issues and update pages issue
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -6,6 +6,8 @@ use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Roles\RoleSwitchRequest;
|
||||
use App\Services\Roles\RoleSwitchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleSwitcherController extends BaseApiController
|
||||
@@ -40,7 +42,13 @@ class RoleSwitcherController extends BaseApiController
|
||||
}
|
||||
|
||||
$role = (string) $request->validated()['role'];
|
||||
$route = $this->switchService->switchRole($role);
|
||||
try {
|
||||
$route = $this->switchService->switchRole($guard, $role);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_FORBIDDEN);
|
||||
} catch (NotFoundHttpException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'role' => $role,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Grading;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Grading\BelowSixtyEmailRequest;
|
||||
use App\Http\Requests\Grading\AllDecisionsRequest;
|
||||
use App\Http\Requests\Grading\BelowSixtyDecisionDetailsRequest;
|
||||
use App\Http\Requests\Grading\BelowSixtyDecisionListRequest;
|
||||
use App\Http\Requests\Grading\BelowSixtyDecisionSaveRequest;
|
||||
@@ -13,6 +14,7 @@ use App\Http\Requests\Grading\BelowSixtyMeetingSaveRequest;
|
||||
use App\Http\Requests\Grading\BelowSixtySendRequest;
|
||||
use App\Http\Requests\Grading\BelowSixtyStatusRequest;
|
||||
use App\Http\Requests\Grading\BelowSixtyListRequest;
|
||||
use App\Http\Requests\Grading\GenerateAllDecisionsRequest;
|
||||
use App\Http\Requests\Grading\GradingLockAllRequest;
|
||||
use App\Http\Requests\Grading\GradingLockRequest;
|
||||
use App\Http\Requests\Grading\GradingOverviewRequest;
|
||||
@@ -316,6 +318,27 @@ class GradingController extends BaseApiController
|
||||
return response()->json(['ok' => true] + $data);
|
||||
}
|
||||
|
||||
public function allDecisions(AllDecisionsRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$data = $this->belowSixtyService->listAllDecisions((string) $payload['school_year']);
|
||||
|
||||
return response()->json(['ok' => true] + $data);
|
||||
}
|
||||
|
||||
public function generateAllDecisions(GenerateAllDecisionsRequest $request): JsonResponse
|
||||
{
|
||||
$userId = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($userId instanceof JsonResponse) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
|
||||
|
||||
return response()->json(['ok' => true, 'saved_count' => $count]);
|
||||
}
|
||||
|
||||
public function belowSixtyDecisions(BelowSixtyDecisionListRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
@@ -47,7 +47,7 @@ class PrintRequestsController extends BaseApiController
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'],
|
||||
'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
'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'],
|
||||
@@ -97,7 +97,7 @@ class PrintRequestsController extends BaseApiController
|
||||
];
|
||||
|
||||
if ($request->hasFile('file') && $request->file('file')->isValid()) {
|
||||
$rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'];
|
||||
$rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Requests\Roles\RoleSwitchRequest;
|
||||
use App\Services\Roles\RoleSwitchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleSwitcherController extends BaseApiController
|
||||
@@ -33,8 +35,19 @@ class RoleSwitcherController extends BaseApiController
|
||||
|
||||
public function switch(RoleSwitchRequest $request): JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return $this->error('Please log in first.', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$role = (string) $request->validated()['role'];
|
||||
$route = $this->switchService->switchRole($role);
|
||||
try {
|
||||
$route = $this->switchService->switchRole($userId, $role);
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_FORBIDDEN);
|
||||
} catch (NotFoundHttpException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'role' => $role,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
|
||||
$headers = $response->headers;
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('X-Frame-Options', 'DENY');
|
||||
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
if ($request->isSecure()) {
|
||||
$headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Grading;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class AllDecisionsRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'school_year' => ['required', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Grading;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class GenerateAllDecisionsRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'school_year' => ['required', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ class User extends Authenticatable implements JWTSubject
|
||||
{
|
||||
return DB::table('user_roles')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->whereNull('user_roles.deleted_at')
|
||||
->where('roles.is_active', 1)
|
||||
->where('user_roles.user_id', $this->id)
|
||||
->pluck('roles.name')
|
||||
->unique()
|
||||
|
||||
@@ -17,6 +17,14 @@ class BroadcastEmailImageService
|
||||
|
||||
public function store(UploadedFile $file): array
|
||||
{
|
||||
if (! $file->isValid()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 400,
|
||||
'error' => 'Invalid upload',
|
||||
];
|
||||
}
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
if (!isset($this->allowedTypes[$mime])) {
|
||||
return [
|
||||
@@ -40,7 +48,7 @@ class BroadcastEmailImageService
|
||||
}
|
||||
|
||||
$ext = $this->allowedTypes[$mime];
|
||||
$newName = uniqid('em_', true) . '.' . $ext;
|
||||
$newName = 'em_' . bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$file->move($targetDir, $newName);
|
||||
|
||||
return [
|
||||
|
||||
@@ -22,6 +22,7 @@ class DashboardRouteService
|
||||
];
|
||||
}
|
||||
|
||||
$currentRole = strtolower(trim((string) session('role', '')));
|
||||
$roles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
@@ -40,7 +41,12 @@ class DashboardRouteService
|
||||
];
|
||||
}
|
||||
|
||||
$rows = Role::findByNamesOrSlugs($roles);
|
||||
$lookupRoles = $roles;
|
||||
if ($currentRole !== '' && in_array($currentRole, $roles, true)) {
|
||||
$lookupRoles = [$currentRole];
|
||||
}
|
||||
|
||||
$rows = Role::findByNamesOrSlugs($lookupRoles);
|
||||
|
||||
if (!empty($rows)) {
|
||||
$role = $rows[0];
|
||||
@@ -51,6 +57,7 @@ class DashboardRouteService
|
||||
'role' => $role->name ?? null,
|
||||
'role_slug' => $role->slug ?? null,
|
||||
'roles' => $roles,
|
||||
'current_role' => $currentRole !== '' ? $currentRole : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,6 +71,7 @@ class DashboardRouteService
|
||||
'role' => null,
|
||||
'role_slug' => null,
|
||||
'roles' => $roles,
|
||||
'current_role' => $currentRole !== '' ? $currentRole : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +103,28 @@ class EventManagementService
|
||||
return null;
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'application/pdf' => 'pdf',
|
||||
];
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
if (! isset($allowed[$mime])) {
|
||||
throw new \InvalidArgumentException('Unsupported flyer file type.');
|
||||
}
|
||||
|
||||
if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
||||
throw new \InvalidArgumentException('Flyer file too large. Max 5MB.');
|
||||
}
|
||||
|
||||
$dir = public_path('uploads/event_flyers');
|
||||
if (!File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$name = $file->hashName();
|
||||
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
|
||||
$file->move($dir, $name);
|
||||
|
||||
return 'event_flyers/' . $name;
|
||||
|
||||
@@ -14,8 +14,30 @@ class ExpenseReceiptService
|
||||
|
||||
public function storeReceipt(UploadedFile $file): string
|
||||
{
|
||||
$stored = $file->store('receipts');
|
||||
return basename($stored);
|
||||
if (! $file->isValid()) {
|
||||
throw new \InvalidArgumentException('Invalid receipt upload.');
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'application/pdf' => 'pdf',
|
||||
];
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
if (! isset($allowed[$mime])) {
|
||||
throw new \InvalidArgumentException('Unsupported receipt file type.');
|
||||
}
|
||||
|
||||
if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
||||
throw new \InvalidArgumentException('Receipt file too large. Max 5MB.');
|
||||
}
|
||||
|
||||
$filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
|
||||
$file->storeAs('receipts', $filename);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function receiptUrl(?string $filename): ?string
|
||||
|
||||
@@ -39,13 +39,10 @@ class FileServeService
|
||||
'Content-Length' => (string) $meta['size'],
|
||||
'ETag' => $meta['etag'],
|
||||
'Last-Modified' => $meta['last_modified'],
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Cache-Control' => 'private, max-age=300',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
|
||||
if ($nosniff) {
|
||||
$headers['X-Content-Type-Options'] = 'nosniff';
|
||||
}
|
||||
|
||||
return response(file_get_contents($meta['path']), 200, $headers);
|
||||
}
|
||||
|
||||
|
||||
@@ -584,6 +584,308 @@ class GradingBelowSixtyService
|
||||
$this->sendEmail($payload);
|
||||
}
|
||||
|
||||
public function listAllDecisions(string $schoolYear): array
|
||||
{
|
||||
$saved = StudentDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
|
||||
$savedMap = [];
|
||||
foreach ($saved as $row) {
|
||||
$studentId = (int) $row->student_id;
|
||||
if ($studentId > 0) {
|
||||
$savedMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$allScoreRows = DB::table('semester_scores as ss')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.gender',
|
||||
'ss.class_section_id',
|
||||
'cs.class_section_name',
|
||||
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students as s', 's.id', '=', 'ss.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
|
||||
->whereNotNull('ss.semester_score')
|
||||
->orderBy('cs.class_section_name')
|
||||
->orderBy('s.lastname')
|
||||
->orderBy('s.firstname')
|
||||
->get();
|
||||
|
||||
$studentMap = [];
|
||||
foreach ($allScoreRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($studentMap[$studentId])) {
|
||||
$studentMap[$studentId] = [
|
||||
'school_id' => $row->school_id,
|
||||
'firstname' => $row->firstname,
|
||||
'lastname' => $row->lastname,
|
||||
'gender' => $row->gender,
|
||||
'class_section_id' => (int) ($row->class_section_id ?? 0),
|
||||
'class_section_name' => $row->class_section_name,
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
|
||||
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
|
||||
if ($semesterKey === 'fall') {
|
||||
$studentMap[$studentId]['fall_score'] = $score;
|
||||
} elseif ($semesterKey === 'spring') {
|
||||
$studentMap[$studentId]['spring_score'] = $score;
|
||||
}
|
||||
}
|
||||
|
||||
$belowRows = BelowSixtyDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', 'year')
|
||||
->get();
|
||||
|
||||
$belowMap = [];
|
||||
foreach ($belowRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
|
||||
$belowMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($studentMap as $studentId => $info) {
|
||||
$fall = $info['fall_score'];
|
||||
$spring = $info['spring_score'];
|
||||
|
||||
if ($fall !== null && $spring !== null) {
|
||||
$yearScore = round(($fall + $spring) / 2, 2);
|
||||
} elseif ($fall !== null) {
|
||||
$yearScore = round((float) $fall, 2);
|
||||
} elseif ($spring !== null) {
|
||||
$yearScore = round((float) $spring, 2);
|
||||
} else {
|
||||
$yearScore = null;
|
||||
}
|
||||
|
||||
if (isset($savedMap[$studentId])) {
|
||||
$savedRow = $savedMap[$studentId];
|
||||
$decision = trim((string) ($savedRow->decision ?? ''));
|
||||
$source = trim((string) ($savedRow->source ?? 'pending'));
|
||||
$notes = (string) ($savedRow->notes ?? '');
|
||||
if (is_numeric($savedRow->year_score ?? null)) {
|
||||
$yearScore = round((float) $savedRow->year_score, 2);
|
||||
}
|
||||
} elseif ($yearScore !== null && $yearScore >= 60) {
|
||||
$decision = 'Pass';
|
||||
$source = 'auto';
|
||||
$notes = '';
|
||||
} elseif ($yearScore !== null && isset($belowMap[$studentId])) {
|
||||
$decision = trim((string) ($belowMap[$studentId]->decision ?? ''));
|
||||
$source = $decision !== '' ? 'manual' : 'pending';
|
||||
$notes = (string) ($belowMap[$studentId]->notes ?? '');
|
||||
} else {
|
||||
$decision = '';
|
||||
$source = 'pending';
|
||||
$notes = '';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $info['school_id'],
|
||||
'firstname' => $info['firstname'],
|
||||
'lastname' => $info['lastname'],
|
||||
'gender' => $info['gender'] ?? '',
|
||||
'class_section_id' => (int) ($info['class_section_id'] ?? 0),
|
||||
'class_section_name' => $info['class_section_name'],
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $yearScore,
|
||||
'decision' => $decision,
|
||||
'source' => $source,
|
||||
'notes' => $notes,
|
||||
'saved' => isset($savedMap[$studentId]),
|
||||
'is_trophy' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$rowsByClass = [];
|
||||
foreach ($rows as $index => $row) {
|
||||
$classSectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rowsByClass[$classSectionId][] = $index;
|
||||
}
|
||||
|
||||
foreach ($rowsByClass as $classIndexes) {
|
||||
$scores = [];
|
||||
foreach ($classIndexes as $rowIndex) {
|
||||
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
|
||||
if (is_numeric($yearScore)) {
|
||||
$scores[] = (float) $yearScore;
|
||||
}
|
||||
}
|
||||
|
||||
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
|
||||
$threshold = $thresholdInfo['threshold'];
|
||||
if ($threshold === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($classIndexes as $rowIndex) {
|
||||
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
|
||||
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float) $yearScore >= $threshold;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'generated' => !empty($savedMap),
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
public function generateAllDecisions(string $schoolYear, ?int $userId): int
|
||||
{
|
||||
$allScoreRows = DB::table('semester_scores as ss')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
'cs.class_section_name',
|
||||
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students as s', 's.id', '=', 'ss.student_id')
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
|
||||
->whereNotNull('ss.semester_score')
|
||||
->get();
|
||||
|
||||
if ($allScoreRows->isEmpty()) {
|
||||
throw new RuntimeException('No semester scores found for this school year.');
|
||||
}
|
||||
|
||||
$studentMap = [];
|
||||
foreach ($allScoreRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($studentMap[$studentId])) {
|
||||
$studentMap[$studentId] = [
|
||||
'firstname' => $row->firstname,
|
||||
'lastname' => $row->lastname,
|
||||
'class_section_name' => $row->class_section_name,
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
|
||||
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
|
||||
if ($semesterKey === 'fall') {
|
||||
$studentMap[$studentId]['fall_score'] = $score;
|
||||
} elseif ($semesterKey === 'spring') {
|
||||
$studentMap[$studentId]['spring_score'] = $score;
|
||||
}
|
||||
}
|
||||
|
||||
$belowRows = BelowSixtyDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', 'year')
|
||||
->get();
|
||||
$belowMap = [];
|
||||
foreach ($belowRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
|
||||
$belowMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$existingRows = StudentDecision::query()
|
||||
->where('school_year', $schoolYear)
|
||||
->get();
|
||||
$existingMap = [];
|
||||
foreach ($existingRows as $row) {
|
||||
$studentId = (int) ($row->student_id ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$existingMap[$studentId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$savedCount = 0;
|
||||
foreach ($studentMap as $studentId => $info) {
|
||||
$fall = $info['fall_score'];
|
||||
$spring = $info['spring_score'];
|
||||
if ($fall !== null && $spring !== null) {
|
||||
$yearScore = round(($fall + $spring) / 2, 2);
|
||||
} elseif ($fall !== null) {
|
||||
$yearScore = round((float) $fall, 2);
|
||||
} elseif ($spring !== null) {
|
||||
$yearScore = round((float) $spring, 2);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($yearScore >= 60) {
|
||||
$decision = 'Pass';
|
||||
$source = 'auto';
|
||||
$notes = null;
|
||||
} elseif (isset($belowMap[$studentId]) && trim((string) ($belowMap[$studentId]->decision ?? '')) !== '') {
|
||||
$decision = trim((string) $belowMap[$studentId]->decision);
|
||||
$source = 'manual';
|
||||
$notes = trim((string) ($belowMap[$studentId]->notes ?? ''));
|
||||
$notes = $notes !== '' ? $notes : null;
|
||||
} else {
|
||||
$decision = null;
|
||||
$source = 'pending';
|
||||
$notes = null;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_name' => $info['class_section_name'] ?? null,
|
||||
'year_score' => $yearScore,
|
||||
'decision' => $decision,
|
||||
'source' => $source,
|
||||
'notes' => $notes,
|
||||
'generated_by' => $userId,
|
||||
];
|
||||
|
||||
if (isset($existingMap[$studentId])) {
|
||||
$existingMap[$studentId]->fill($payload)->save();
|
||||
} else {
|
||||
StudentDecision::query()->create($payload);
|
||||
}
|
||||
|
||||
$savedCount++;
|
||||
}
|
||||
|
||||
return $savedCount;
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
@@ -866,6 +1168,108 @@ class GradingBelowSixtyService
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
$scores,
|
||||
static fn ($value): bool => is_numeric($value) && $value !== null
|
||||
));
|
||||
$scores = array_map('floatval', $scores);
|
||||
sort($scores);
|
||||
|
||||
$count = count($scores);
|
||||
if ($count === 0) {
|
||||
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
|
||||
}
|
||||
|
||||
$minWinners = 3;
|
||||
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
|
||||
|
||||
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
if ($winners < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
|
||||
}
|
||||
|
||||
if ($winners <= $maxWinners) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
|
||||
}
|
||||
|
||||
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
|
||||
if ($result['winners'] < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
|
||||
{
|
||||
$descending = array_reverse($sortedScores);
|
||||
$threshold = $descending[$max - 1];
|
||||
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
|
||||
|
||||
if ($winners <= $max) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
|
||||
}
|
||||
|
||||
$uniqueHigherScores = array_values(array_unique(array_filter(
|
||||
$sortedScores,
|
||||
static fn ($score): bool => $score > $threshold
|
||||
)));
|
||||
sort($uniqueHigherScores);
|
||||
|
||||
foreach ($uniqueHigherScores as $candidate) {
|
||||
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
|
||||
if ($winnerCount <= $max) {
|
||||
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
|
||||
}
|
||||
|
||||
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
|
||||
{
|
||||
$count = count($sortedScores);
|
||||
if ($count === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($count === 1) {
|
||||
return (float) $sortedScores[0];
|
||||
}
|
||||
|
||||
$rank = ($percentile / 100) * ($count - 1);
|
||||
$lowerIndex = (int) floor($rank);
|
||||
$upperIndex = (int) ceil($rank);
|
||||
$weight = $rank - $lowerIndex;
|
||||
|
||||
$lower = (float) $sortedScores[$lowerIndex];
|
||||
$upper = (float) $sortedScores[$upperIndex];
|
||||
|
||||
return $lower + (($upper - $lower) * $weight);
|
||||
}
|
||||
|
||||
private function countScoresAtOrAbove(array $scores, float $threshold): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$scores,
|
||||
static fn ($score): bool => is_numeric($score) && (float) $score >= $threshold
|
||||
));
|
||||
}
|
||||
|
||||
private function buildDecisionEmailDetailsHtml(array $semesters): string
|
||||
{
|
||||
if (empty($semesters)) {
|
||||
|
||||
@@ -387,7 +387,7 @@ class PrintRequestsPortalService
|
||||
public function storeTeacherUpload(array $data, UploadedFile $file, int $teacherId): PrintRequest
|
||||
{
|
||||
$this->ensureStorageDirectoryExists();
|
||||
$ext = $file->getClientOriginalExtension();
|
||||
$ext = $this->safeExtensionForUpload($file);
|
||||
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
|
||||
$file->move($this->storageDirectory(), $stored);
|
||||
|
||||
@@ -432,7 +432,7 @@ class PrintRequestsPortalService
|
||||
}
|
||||
}
|
||||
}
|
||||
$ext = $newFile->getClientOriginalExtension();
|
||||
$ext = $this->safeExtensionForUpload($newFile);
|
||||
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
|
||||
$newFile->move($this->storageDirectory(), $stored);
|
||||
$update['file_path'] = $stored;
|
||||
@@ -445,6 +445,29 @@ class PrintRequestsPortalService
|
||||
return $request->fresh();
|
||||
}
|
||||
|
||||
private function safeExtensionForUpload(UploadedFile $file): string
|
||||
{
|
||||
$allowed = [
|
||||
'application/pdf' => 'pdf',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'text/plain' => 'txt',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
];
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
if (! isset($allowed[$mime])) {
|
||||
throw new \InvalidArgumentException('Unsupported upload type.');
|
||||
}
|
||||
|
||||
if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
||||
throw new \InvalidArgumentException('Uploaded file too large. Max 5MB.');
|
||||
}
|
||||
|
||||
return $allowed[$mime];
|
||||
}
|
||||
|
||||
public function applyAdminStatus(PrintRequest $request, string $newStatus, int $adminUserId): PrintRequest
|
||||
{
|
||||
$current = (string) $request->status;
|
||||
|
||||
@@ -1081,8 +1081,6 @@ class ReportCardService
|
||||
|
||||
private function formatReportPDF(\FPDF $pdf, array $data): void
|
||||
{
|
||||
require_once base_path('app/ThirdParty/fpdf/fpdf.php');
|
||||
|
||||
$w = $pdf->GetPageWidth();
|
||||
$pdf->SetMargins(10, 10, 10);
|
||||
$bottomMarginVal = 3;
|
||||
|
||||
@@ -12,13 +12,33 @@ class RoleQueryService
|
||||
public function listRoles(array $filters): LengthAwarePaginator
|
||||
{
|
||||
$query = Role::query()
|
||||
->select('roles.*')
|
||||
->select([
|
||||
'roles.id',
|
||||
'roles.name',
|
||||
'roles.slug',
|
||||
'roles.description',
|
||||
'roles.dashboard_route',
|
||||
'roles.priority',
|
||||
'roles.is_active',
|
||||
'roles.created_at',
|
||||
'roles.updated_at',
|
||||
])
|
||||
->leftJoin('user_roles', function ($join) {
|
||||
$join->on('user_roles.role_id', '=', 'roles.id')
|
||||
->whereNull('user_roles.deleted_at');
|
||||
})
|
||||
->selectRaw('COUNT(DISTINCT user_roles.user_id) AS user_count')
|
||||
->groupBy('roles.id');
|
||||
->groupBy([
|
||||
'roles.id',
|
||||
'roles.name',
|
||||
'roles.slug',
|
||||
'roles.description',
|
||||
'roles.dashboard_route',
|
||||
'roles.priority',
|
||||
'roles.is_active',
|
||||
'roles.created_at',
|
||||
'roles.updated_at',
|
||||
]);
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$search = trim((string) $filters['search']);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Services\Roles;
|
||||
|
||||
use App\Models\UserRole;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class RoleSwitchService
|
||||
{
|
||||
@@ -16,8 +18,30 @@ class RoleSwitchService
|
||||
return array_values(array_filter(array_map(fn ($r) => (string) ($r['role_name'] ?? ''), $roles)));
|
||||
}
|
||||
|
||||
public function switchRole(string $role): string
|
||||
public function switchRole(int $userId, string $role): string
|
||||
{
|
||||
return $this->dashboardService->bestDashboardRouteFor([$role]);
|
||||
$requested = strtolower(trim($role));
|
||||
if ($requested === '') {
|
||||
throw new NotFoundHttpException('Role not found.');
|
||||
}
|
||||
|
||||
$roles = $this->getUserRoleNames($userId);
|
||||
$matchedRole = null;
|
||||
|
||||
foreach ($roles as $assignedRole) {
|
||||
$normalized = strtolower(trim($assignedRole));
|
||||
if ($normalized === $requested) {
|
||||
$matchedRole = $assignedRole;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRole === null) {
|
||||
throw new AccessDeniedHttpException('Invalid role selected.');
|
||||
}
|
||||
|
||||
session()->put('role', $matchedRole);
|
||||
|
||||
return $this->dashboardService->bestDashboardRouteFor([$matchedRole]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user