fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
API CI/CD / Validate (composer + pint) (push) Successful in 3m10s
API CI/CD / Test (PHPUnit) (push) Failing after 6m49s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 1m0s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-07 01:52:29 -04:00
parent 58726ee0e9
commit 031e499819
35 changed files with 5633 additions and 182 deletions
@@ -9,9 +9,9 @@ use App\Http\Requests\Families\FamilyAdminSearchRequest;
use App\Http\Requests\Families\FamilyComposeEmailRequest;
use App\Http\Resources\Families\FamilyResource;
use App\Http\Resources\Families\FamilySearchItemResource;
use App\Models\Configuration;
use App\Services\Families\FamilyNotificationService;
use App\Services\Families\FamilyQueryService;
use App\Services\SchoolYears\SelectedSchoolYearContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
@@ -20,7 +20,8 @@ class FamilyAdminController extends BaseApiController
{
public function __construct(
private FamilyQueryService $queryService,
private FamilyNotificationService $notificationService
private FamilyNotificationService $notificationService,
private SelectedSchoolYearContextService $schoolYears
) {
parent::__construct();
}
@@ -28,7 +29,7 @@ class FamilyAdminController extends BaseApiController
public function index(FamilyAdminIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = $this->requestedSchoolYear($request, $payload);
$schoolYear = $this->requestedSchoolYear($request);
$data = $this->queryService->adminIndexData(
$payload['student_id'] ?? null,
@@ -63,7 +64,7 @@ class FamilyAdminController extends BaseApiController
public function card(FamilyAdminCardRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = $this->requestedSchoolYear($request, $payload);
$schoolYear = $this->requestedSchoolYear($request);
$family = $this->queryService->familyCard(
$payload['student_id'] ?? null,
@@ -79,24 +80,9 @@ class FamilyAdminController extends BaseApiController
return $this->success(new FamilyResource($family));
}
private function requestedSchoolYear($request, array $payload): string
private function requestedSchoolYear($request): string
{
$candidates = [
$payload['school_year'] ?? null,
$request->query('school_year'),
$request->header('X-School-Year'),
$request->header('X-Selected-School-Year'),
Configuration::getConfig('school_year'),
];
foreach ($candidates as $candidate) {
$value = trim((string) ($candidate ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
return $this->schoolYears->fromRequest($request)->name;
}
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\Families\ParentProfileAdminQueryService;
use App\Services\SchoolYears\SelectedSchoolYearContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ParentProfileAdminController extends BaseApiController
{
public function __construct(
private ParentProfileAdminQueryService $profiles,
private SelectedSchoolYearContextService $schoolYears,
) {
parent::__construct();
}
public function index(Request $request): JsonResponse
{
if ($request->has('school_year_id')) {
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$context = $this->schoolYears->fromRequest($request);
$page = $this->profiles->index(
$context->name,
trim((string) $request->query('q', '')),
(int) $request->query('per_page', 25),
);
return $this->success([
'school_year' => $context->name,
'read_only' => $context->isReadOnly,
'parents' => $page->getCollection()
->map(fn ($row) => $this->profiles->parentSummary((array) $row, $context->name))
->values()
->all(),
'pagination' => [
'page' => $page->currentPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
],
]);
}
public function search(Request $request): JsonResponse
{
if ($request->has('school_year_id')) {
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$context = $this->schoolYears->fromRequest($request);
return $this->success([
'school_year' => $context->name,
'read_only' => $context->isReadOnly,
'items' => $this->profiles->search(
$context->name,
trim((string) $request->query('q', '')),
(int) $request->query('limit', 10),
),
]);
}
public function show(Request $request, int $parent): JsonResponse
{
if ($request->has('school_year_id')) {
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$context = $this->schoolYears->fromRequest($request);
$payload = $this->profiles->show($parent, $context->name);
if (! $payload) {
return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'school_year' => $context->name,
'read_only' => $context->isReadOnly,
...$payload,
]);
}
public function update(Request $request, int $parent): JsonResponse
{
if ($request->has('school_year_id')) {
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$payload = $request->validate([
'firstname' => ['sometimes', 'required', 'string', 'max:100'],
'lastname' => ['sometimes', 'required', 'string', 'max:100'],
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
'cellphone' => ['sometimes', 'nullable', 'string', 'max:30'],
'address_street' => ['sometimes', 'nullable', 'string', 'max:255'],
'city' => ['sometimes', 'nullable', 'string', 'max:100'],
'state' => ['sometimes', 'nullable', 'string', 'max:100'],
'zip' => ['sometimes', 'nullable', 'string', 'max:30'],
'status' => ['sometimes', 'nullable', 'string', 'max:30'],
]);
$updated = $this->profiles->updateParent($parent, $payload);
if (! $updated) {
return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND);
}
return $this->success(['parent' => $updated], 'Parent profile updated.');
}
}
@@ -8,6 +8,7 @@ use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest;
use App\Http\Requests\SchoolYears\SaveSchoolYearRequest;
use App\Services\SchoolYears\SchoolYearClosureService;
use App\Services\SchoolYears\SchoolYearContextService;
use App\Services\SchoolYears\SelectedSchoolYearContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -17,7 +18,8 @@ class SchoolYearController extends BaseApiController
{
public function __construct(
private SchoolYearClosureService $service,
private SchoolYearContextService $context
private SchoolYearContextService $context,
private SelectedSchoolYearContextService $selectedSchoolYear
) {
parent::__construct();
}
@@ -71,11 +73,28 @@ class SchoolYearController extends BaseApiController
]);
}
public function updateSelected(SaveSchoolYearRequest $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->updateYear($selected->id, $request->validated(), $this->getCurrentUserId()),
]);
}
public function summary(int $schoolYear): JsonResponse
{
return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]);
}
public function summarySelected(Request $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json(['ok' => true, 'data' => $this->service->summaryByYearName($selected->name)]);
}
public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
{
return response()->json([
@@ -84,6 +103,16 @@ class SchoolYearController extends BaseApiController
]);
}
public function validateCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'validation' => $this->service->validateCloseByYearName($selected->name, $request->validated()),
]);
}
public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
{
return response()->json([
@@ -92,6 +121,16 @@ class SchoolYearController extends BaseApiController
]);
}
public function previewCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->previewCloseByYearName($selected->name, $request->validated()),
]);
}
public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse
{
try {
@@ -108,6 +147,24 @@ class SchoolYearController extends BaseApiController
return response()->json(['ok' => true, 'data' => $result]);
}
public function closeSelected(CloseSchoolYearRequest $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
try {
$result = $this->service->closeByYearName($selected->name, $request->validated(), $this->getCurrentUserId());
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], Response::HTTP_CONFLICT);
}
return response()->json(['ok' => true, 'data' => $result]);
}
public function reopen(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
@@ -116,6 +173,16 @@ class SchoolYearController extends BaseApiController
]);
}
public function reopenSelected(Request $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->reopenByYearName($selected->name, $this->getCurrentUserId()),
]);
}
public function parentBalances(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
@@ -124,6 +191,16 @@ class SchoolYearController extends BaseApiController
]);
}
public function parentBalancesSelected(Request $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->parentBalancesByYearName($selected->name, $request->query('to_school_year')),
]);
}
public function promotionPreview(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
@@ -132,6 +209,16 @@ class SchoolYearController extends BaseApiController
]);
}
public function promotionPreviewSelected(Request $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->promotionPreviewByYearName($selected->name, $request->query('to_school_year')),
]);
}
public function closingReport(int $schoolYear): JsonResponse
{
return response()->json([
@@ -140,6 +227,16 @@ class SchoolYearController extends BaseApiController
]);
}
public function closingReportSelected(Request $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->closingReportByYearName($selected->name),
]);
}
public function archive(Request $request, int $schoolYear): JsonResponse
{
return response()->json([
@@ -147,4 +244,14 @@ class SchoolYearController extends BaseApiController
'data' => $this->service->archive($schoolYear, $this->getCurrentUserId()),
]);
}
public function archiveSelected(Request $request): JsonResponse
{
$selected = $this->selectedSchoolYear->fromRequest($request);
return response()->json([
'ok' => true,
'data' => $this->service->archiveByYearName($selected->name, $this->getCurrentUserId()),
]);
}
}
@@ -2,17 +2,20 @@
namespace App\Http\Middleware;
use App\Models\Configuration;
use App\Services\SchoolYears\SelectedSchoolYearContextService;
use App\Services\SchoolYears\SchoolYearWriteGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpFoundation\Response;
class EnsureSchoolYearEditable
{
public function __construct(private SchoolYearWriteGuard $guard) {}
public function __construct(
private SchoolYearWriteGuard $guard,
private SelectedSchoolYearContextService $selectedSchoolYear
) {}
public function handle(Request $request, Closure $next): Response
{
@@ -22,11 +25,16 @@ class EnsureSchoolYearEditable
try {
$this->guard->assertEditable($this->resolveSchoolYears($request));
} catch (RuntimeException $e) {
} catch (HttpExceptionInterface $e) {
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], 409);
], $e->getStatusCode());
} catch (\RuntimeException $e) {
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], Response::HTTP_CONFLICT);
}
return $next($request);
@@ -36,11 +44,11 @@ class EnsureSchoolYearEditable
{
$years = [];
foreach (['school_year', 'current_school_year'] as $field) {
$value = $request->input($field, $request->query($field));
if (is_string($value) && trim($value) !== '') {
$years[] = trim($value);
}
$years[] = $this->selectedSchoolYear->fromRequest($request)->name;
$currentSchoolYear = $request->input('current_school_year', $request->query('current_school_year'));
if (is_string($currentSchoolYear) && trim($currentSchoolYear) !== '') {
$years[] = trim($currentSchoolYear);
}
$routeYears = [
@@ -69,14 +77,6 @@ class EnsureSchoolYearEditable
$years[] = $resolved;
}
}
if ($years === []) {
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
if ($current !== '') {
$years[] = $current;
}
}
return $years;
}
@@ -16,7 +16,7 @@ class ClassProgressIndexRequest extends ClassProgressFormRequest
return [
'class_section_id' => ['nullable', 'integer', 'min:1'],
'teacher_id' => ['nullable', 'integer', 'min:1'],
'school_year_id' => ['nullable', 'integer', 'min:1'],
'school_year_id' => ['prohibited'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
'week_start' => ['nullable', 'date_format:Y-m-d'],
@@ -17,6 +17,7 @@ class FamilyAdminCardRequest extends ApiFormRequest
'student_id' => ['nullable', 'integer', 'min:1'],
'guardian_id' => ['nullable', 'integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:20'],
'school_year_id' => ['prohibited'],
'family_id' => ['nullable', 'integer', 'min:1'],
];
}
@@ -17,6 +17,7 @@ class FamilyAdminIndexRequest extends ApiFormRequest
'student_id' => ['nullable', 'integer', 'min:1'],
'guardian_id' => ['nullable', 'integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:20'],
'school_year_id' => ['prohibited'],
];
}
}
@@ -13,6 +13,10 @@ class FamilyComposeEmailRequest extends ApiFormRequest
if (! $this->has('html_message') && $this->has('html')) {
$this->merge(['html_message' => $this->input('html')]);
}
if (! $this->has('recipient') && $this->has('to')) {
$this->merge(['recipient' => $this->input('to')]);
}
}
public function authorize(): bool
@@ -29,6 +33,7 @@ class FamilyComposeEmailRequest extends ApiFormRequest
'profile' => ['nullable', 'string', 'max:50'],
'reply_to_email' => ['nullable', 'email', 'max:255'],
'reply_to_name' => ['nullable', 'string', 'max:255'],
'return_url' => ['nullable', 'string', 'max:2048'],
];
}
}
@@ -10,7 +10,7 @@ class ParentAttendanceRequest extends ApiFormRequest
{
return [
'school_year' => ['nullable', 'string', 'max:20'],
'school_year_id' => ['nullable', 'integer'],
'school_year_id' => ['prohibited'],
];
}
}
@@ -12,7 +12,10 @@ class StaffIndexRequest extends ApiFormRequest
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'role' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', 'string', 'max:40'],
'search' => ['nullable', 'string', 'max:150'],
'school_year' => ['nullable', 'string', 'max:20'],
'school_year_id' => ['prohibited'],
'semester' => ['nullable', 'string', 'max:20'],
'sort_by' => ['nullable', 'in:created_at,lastname,firstname'],
'sort_dir' => ['nullable', 'in:asc,desc'],
@@ -14,8 +14,11 @@ class StaffStoreRequest extends ApiFormRequest
'lastname' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:150'],
'phone' => ['nullable', 'string', 'max:20'],
'role_name' => ['required', 'string', 'max:100'],
'school_year' => ['nullable', 'string', 'max:20'],
'password' => ['nullable', 'string', 'min:8', 'max:120'],
'role_id' => ['nullable', 'integer', 'min:1', 'exists:roles,id'],
'role_name' => ['nullable', 'required_without:role_id', 'string', 'max:100'],
'school_year' => ['required', 'string', 'max:20'],
'school_year_id' => ['prohibited'],
'status' => ['nullable', 'string', 'max:40'],
];
}
@@ -13,8 +13,10 @@ class StaffUpdateRequest extends ApiFormRequest
'lastname' => ['nullable', 'string', 'max:100'],
'email' => ['nullable', 'email', 'max:150'],
'phone' => ['nullable', 'string', 'max:20'],
'role_id' => ['nullable', 'integer', 'min:1', 'exists:roles,id'],
'role_name' => ['nullable', 'string', 'max:100'],
'school_year' => ['nullable', 'string', 'max:20'],
'school_year_id' => ['prohibited'],
'status' => ['nullable', 'string', 'max:40'],
];
}
@@ -16,6 +16,12 @@ class StaffResource extends JsonResource
'lastname' => $this->lastname ?? null,
'email' => $this->email ?? null,
'phone' => $this->phone ?? null,
'full_name' => trim((string) ($this->firstname ?? '').' '.(string) ($this->lastname ?? '')),
'role' => [
'name' => $this->role_name ?? $this->active_role ?? null,
'label' => $this->role_name ?? $this->active_role ?? null,
],
'status' => strtolower((string) ($this->active_role ?? '')) === 'inactive' ? 'inactive' : 'active',
'role_name' => $this->role_name ?? null,
'active_role' => $this->active_role ?? null,
'school_year' => $this->school_year ?? null,