update api and add more features

This commit is contained in:
root
2026-06-04 02:24:41 -04:00
parent fa6c9519a0
commit 4e33882ac7
131 changed files with 34596 additions and 340 deletions
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,375 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Promotions\AdminListPromotionsRequest;
use App\Http\Requests\Promotions\EvaluateEligibilityRequest;
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
use App\Http\Requests\Promotions\SendReminderRequest;
use App\Http\Requests\Promotions\SetEnrollmentDeadlineRequest;
use App\Http\Requests\Promotions\UpdatePromotionStatusRequest;
use App\Http\Requests\Promotions\UpsertLevelProgressionRequest;
use App\Http\Resources\Promotions\LevelProgressionResource;
use App\Http\Resources\Promotions\PromotionAuditLogResource;
use App\Http\Resources\Promotions\PromotionReminderResource;
use App\Http\Resources\Promotions\StudentPromotionResource;
use App\Models\PromotionAuditLog;
use App\Models\PromotionReminderLog;
use App\Models\StudentPromotionRecord;
use App\Services\Promotions\LevelProgressionService;
use App\Services\Promotions\PromotionAuditService;
use App\Services\Promotions\PromotionEligibilityService;
use App\Services\Promotions\PromotionEnrollmentService;
use App\Services\Promotions\PromotionQueryService;
use App\Services\Promotions\PromotionReminderService;
use App\Services\Promotions\PromotionStatusService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* Admin / registrar API for managing the promotion + parent enrollment
* lifecycle (plan sections 13, 16, 17).
*/
class AdministratorPromotionController extends BaseApiController
{
public function __construct(
private PromotionQueryService $query,
private PromotionEligibilityService $eligibility,
private PromotionStatusService $statusService,
private PromotionEnrollmentService $enrollmentService,
private PromotionReminderService $reminders,
private PromotionAuditService $audit,
private LevelProgressionService $progression
) {
parent::__construct();
}
public function index(AdminListPromotionsRequest $request): JsonResponse
{
$result = $this->query->list($request->filters());
return response()->json([
'ok' => true,
'data' => StudentPromotionResource::collection($result['data']),
'pagination' => [
'page' => $result['page'],
'per_page' => $result['per_page'],
'total' => $result['total'],
'total_pages' => $result['total_pages'],
],
'counts_by_status' => $result['counts_by_status'],
]);
}
public function show(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($record)),
]);
}
public function summary(AdminListPromotionsRequest $request): JsonResponse
{
$counts = $this->query->countsByStatus($request->filters());
return response()->json([
'ok' => true,
'counts_by_status' => $counts,
'total' => array_sum($counts),
]);
}
public function evaluate(EvaluateEligibilityRequest $request): JsonResponse
{
$payload = $request->validated();
$userId = $this->getCurrentUserId();
$scope = $payload['scope'] ?? 'school_year';
try {
$result = match ($scope) {
'student' => [
'scope' => 'student',
'record' => optional(
$this->eligibility->evaluateStudent(
(int) $payload['student_id'],
$payload['current_school_year'] ?? null,
$userId
)
)?->toArray(),
],
'class_section' => array_merge(
['scope' => 'class_section'],
$this->eligibility->evaluateClassSection(
(int) $payload['class_section_id'],
$payload['current_school_year'] ?? null,
$userId
)
),
default => array_merge(
['scope' => 'school_year'],
$this->eligibility->evaluateSchoolYear(
$payload['current_school_year'] ?? null,
$userId
)
),
};
} catch (\Throwable $e) {
Log::error('Promotion eligibility evaluation failed', ['exception' => $e]);
return response()->json([
'ok' => false,
'message' => 'Failed to evaluate promotion eligibility.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$payload = $request->validated();
$userId = $this->getCurrentUserId();
try {
$updated = !empty($payload['force'])
? $this->statusService->forceStatus($record, $payload['status'], $userId, $payload['notes'] ?? null)
: $this->statusService->transition($record, $payload['status'], $userId, $payload['notes'] ?? null);
} catch (\InvalidArgumentException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function setDeadline(SetEnrollmentDeadlineRequest $request, ?int $promotionId = null): JsonResponse
{
$payload = $request->validated();
$userId = $this->getCurrentUserId();
$newDeadline = $payload['enrollment_deadline'];
$applyTo = $payload['apply_to'] ?? ($promotionId ? 'single' : 'school_year');
$records = collect();
if ($promotionId !== null || $applyTo === 'single') {
$id = $promotionId ?? null;
if ($id === null) {
return response()->json(['ok' => false, 'message' => 'promotion_id is required for single deadline updates.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$record = $this->findOrFail($id);
if ($record instanceof JsonResponse) {
return $record;
}
$records = collect([$record]);
} elseif ($applyTo === 'filter' && !empty($payload['promotion_ids'])) {
$records = StudentPromotionRecord::query()
->whereIn('promotion_id', $payload['promotion_ids'])
->get();
} else {
$year = $payload['next_school_year'] ?? null;
if ($year === null || $year === '') {
return response()->json([
'ok' => false,
'message' => 'next_school_year is required when applying a bulk deadline.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$records = StudentPromotionRecord::query()
->forNextYear($year)
->whereIn('promotion_status', StudentPromotionRecord::openStatuses())
->get();
}
$updatedIds = [];
foreach ($records as $record) {
$oldDeadline = $record->enrollment_deadline?->toDateString();
DB::transaction(function () use ($record, $newDeadline, $oldDeadline, $userId, &$updatedIds) {
$record->enrollment_deadline = $newDeadline;
$record->updated_by = $userId;
$record->save();
$this->audit->logDeadlineSet($record, $oldDeadline, $newDeadline, $userId);
$updatedIds[] = (int) $record->getKey();
});
}
return response()->json([
'ok' => true,
'updated' => count($updatedIds),
'promotion_ids' => $updatedIds,
]);
}
public function sendReminder(SendReminderRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$payload = $request->validated();
$userId = $this->getCurrentUserId();
try {
$log = $this->reminders->send(
$record,
$payload['reminder_type'] ?? PromotionReminderLog::TYPE_MANUAL,
$userId,
$payload['subject'] ?? null,
$payload['message'] ?? null,
$payload['channels'] ?? ['in_app', 'email']
);
} catch (\Throwable $e) {
Log::error('Promotion reminder send failed', [
'promotion_id' => $promotionId,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Failed to send reminder.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'ok' => true,
'reminder' => new PromotionReminderResource($log),
]);
}
public function dispatchScheduledReminders(): JsonResponse
{
$userId = $this->getCurrentUserId();
$result = $this->reminders->dispatchScheduledReminders(null, $userId);
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function reminders(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$rows = PromotionReminderLog::query()
->forPromotion((int) $record->getKey())
->orderByDesc('id')
->get();
return response()->json([
'ok' => true,
'reminders' => PromotionReminderResource::collection($rows),
]);
}
public function audit(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$rows = PromotionAuditLog::query()
->forPromotion((int) $record->getKey())
->orderByDesc('id')
->get();
return response()->json([
'ok' => true,
'entries' => PromotionAuditLogResource::collection($rows),
]);
}
public function expireDeadlines(): JsonResponse
{
$result = $this->enrollmentService->markExpiredDeadlines(null, $this->getCurrentUserId());
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function adminCompleteEnrollment(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$userId = $this->getCurrentUserId();
$parentId = (int) $record->parent_id ?: $userId ?: 0;
if ($parentId <= 0) {
return response()->json([
'ok' => false,
'message' => 'Promotion record has no associated parent.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$updated = $this->enrollmentService->updateSteps(
$record,
$parentId,
$request->steps(),
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function levelProgressions(): JsonResponse
{
$rows = $this->progression->list(false);
return response()->json([
'ok' => true,
'levels' => LevelProgressionResource::collection($rows),
]);
}
public function upsertLevelProgression(UpsertLevelProgressionRequest $request): JsonResponse
{
$row = $this->progression->upsertMapping($request->validated());
return response()->json([
'ok' => true,
'level' => new LevelProgressionResource($row),
]);
}
public function seedLevelProgressions(): JsonResponse
{
$created = $this->progression->seedDefaults();
return response()->json([
'ok' => true,
'created' => $created,
]);
}
private function findOrFail(int $promotionId): StudentPromotionRecord|JsonResponse
{
$record = StudentPromotionRecord::query()->find($promotionId);
if (!$record) {
return response()->json([
'ok' => false,
'message' => 'Promotion record not found.',
], Response::HTTP_NOT_FOUND);
}
return $record;
}
}
@@ -20,8 +20,7 @@ class AuthController extends BaseApiController
$payload = is_array($decoded) ? $decoded : [];
}
// Match CodeIgniter apiLogin: raw email/password (no trim); empty check is identical to CI `!$email || !$password`.
$email = (string) ($payload['email'] ?? '');
$email = strtolower(trim((string) ($payload['email'] ?? '')));
$password = (string) ($payload['password'] ?? '');
if ($email === '' || $password === '') {
@@ -39,7 +38,7 @@ class AuthController extends BaseApiController
], 429);
}
$user = User::query()->where('email', $email)->first();
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if (!$user) {
$security->logIpAttempt((string) $ip);
@@ -49,13 +48,6 @@ class AuthController extends BaseApiController
], 401);
}
if ($user->is_suspended) {
return response()->json([
'status' => false,
'message' => 'Account suspended. Please reset your password.',
], 403);
}
if (! ci_password_verify($password, (string) $user->password)) {
$security->handleFailedLogin($user, $email, (string) $ip);
@@ -65,6 +57,15 @@ class AuthController extends BaseApiController
], 401);
}
// Suspension is checked AFTER credentials are verified so that the
// response shape cannot be used to enumerate which emails exist.
if ($user->is_suspended) {
return response()->json([
'status' => false,
'message' => 'Account suspended. Please reset your password.',
], 403);
}
$security->resetFailedAttempts($user);
$security->logSuccessfulLogin($user->fresh(), $request);
@@ -245,28 +245,31 @@ class BaseApiController extends Controller
return null;
}
$user = app(User::class);
$row = $user->find($userId);
/** @var User|null $row */
$row = User::query()->find($userId);
if (!$row) {
return null;
}
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
$firstname = (string) ($row->firstname ?? '');
$lastname = (string) ($row->lastname ?? '');
$email = $row->email;
$name = trim($firstname . ' ' . $lastname);
if ($name === '') {
$name = $row['email'] ?? 'User #' . $userId;
$name = $email ?? 'User #' . $userId;
}
try {
$roles = DB::table('user_roles ur')
->select('LOWER(r.name) AS name')
->join('roles r', 'r.id', '=', 'ur.role_id')
$roles = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at')
->pluck('name')
->map(fn($name) => strtolower((string) $name))
->pluck('r.name')
->map(fn ($name) => strtolower((string) $name))
->unique()
->values()
->toArray();
->all();
} catch (\Throwable $e) {
Log::error('Unable to load user roles: ' . $e->getMessage());
$roles = [];
@@ -275,7 +278,7 @@ class BaseApiController extends Controller
return (object) [
'id' => (int) $userId,
'name' => $name,
'email' => $row['email'] ?? null,
'email' => $email,
'roles' => $roles,
];
}
@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\ApplyExtraChargeRequest;
use App\Http\Requests\Finance\ChargeListRequest;
use App\Http\Requests\Finance\MarkEventChargePaidRequest;
use App\Http\Requests\Finance\StoreEventChargeRequest;
use App\Http\Requests\Finance\StoreExtraChargeRequest;
use App\Http\Resources\Fees\ChargeActionResource;
use App\Http\Resources\Fees\ChargeListResource;
use App\Services\Billing\ChargeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ChargeController extends BaseApiController
{
public function __construct(private ChargeService $charges)
{
parent::__construct();
}
public function index(ChargeListRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->listForParent(
(int) $payload['parent_id'],
$payload['school_year'] ?? null
);
return response()->json([
'ok' => true,
'charges' => new ChargeListResource($result),
]);
}
public function storeExtra(StoreExtraChargeRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['created_by'] = $guard;
try {
$result = $this->charges->createExtraCharge($payload);
} catch (\Throwable $e) {
Log::error('Store extra charge failed', ['exception' => $e]);
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function storeEvent(StoreEventChargeRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['created_by'] = $guard;
try {
$result = $this->charges->createEventCharge($payload);
} catch (\Throwable $e) {
Log::error('Store event charge failed', ['exception' => $e]);
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function cancelExtra(int $id): JsonResponse
{
$result = $this->charges->cancelExtraCharge($id);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function applyExtra(ApplyExtraChargeRequest $request, int $id): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->applyExtraCharge($id, (int) $payload['invoice_id']);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function cancelEvent(Request $request, int $id): JsonResponse
{
$issueCredit = $request->boolean('issue_credit', true);
$result = $this->charges->cancelEventCharge($id, $issueCredit);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function markEventPaid(MarkEventChargePaidRequest $request, int $id): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->markEventChargePaid(
$id,
(bool) $payload['paid'],
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $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;
}
}
@@ -3,12 +3,18 @@
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FeeFamilyBalanceRequest;
use App\Http\Requests\Finance\FeeRefundRequest;
use App\Http\Requests\Finance\FeeTuitionBreakdownRequest;
use App\Http\Requests\Finance\FeeTuitionTotalRequest;
use App\Http\Resources\Fees\FeeFamilyBalanceResource;
use App\Http\Resources\Fees\FeeRefundResource;
use App\Http\Resources\Fees\FeeTuitionBreakdownResource;
use App\Http\Resources\Fees\FeeTuitionTotalResource;
use App\Services\Billing\BalanceCalculationService;
use App\Services\Fees\FeeRefundCalculatorService;
use App\Services\Fees\FeeStudentFeeService;
use App\Services\Fees\TuitionCalculationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
@@ -16,7 +22,9 @@ class FeeCalculationController extends BaseApiController
{
public function __construct(
private FeeRefundCalculatorService $refunds,
private FeeStudentFeeService $tuition
private FeeStudentFeeService $tuition,
private TuitionCalculationService $tuitionCalculator,
private BalanceCalculationService $balance
) {
parent::__construct();
}
@@ -69,4 +77,55 @@ class FeeCalculationController extends BaseApiController
]),
]);
}
public function tuitionBreakdown(FeeTuitionBreakdownRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->tuitionCalculator->calculate($payload['students']);
} catch (\Throwable $e) {
Log::error('Tuition breakdown calculation failed', [
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate tuition breakdown.',
], 500);
}
return response()->json([
'ok' => true,
'tuition' => new FeeTuitionBreakdownResource($result),
]);
}
public function familyBalance(FeeFamilyBalanceRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->balance->calculateFamilyAccount(
$payload['students'],
(int) $payload['parent_id'],
$payload['school_year'] ?? null
);
} catch (\Throwable $e) {
Log::error('Family balance calculation failed', [
'parent_id' => $payload['parent_id'] ?? null,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate family balance.',
], 500);
}
return response()->json([
'ok' => true,
'account' => new FeeFamilyBalanceResource($result),
]);
}
}
@@ -37,14 +37,19 @@ class AuthorizedUserInviteController extends Controller
);
}
$redirectUrl = (string) $result['redirect_url'];
if (! $this->isTrustedRedirectUrl($redirectUrl)) {
return response()->json(['message' => 'Invalid redirect URL.'], 400);
}
if ($request->expectsJson()) {
return response()->json([
'message' => 'Confirmed.',
'redirect_url' => $result['redirect_url'],
'redirect_url' => $redirectUrl,
]);
}
return redirect()->away($result['redirect_url']);
return redirect()->away($redirectUrl);
}
public function setPasswordForm(Request $request, int $authorizedUserId): JsonResponse|Response
@@ -79,7 +84,8 @@ class AuthorizedUserInviteController extends Controller
{
$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'],
'password_confirmation' => ['required_without:password_confirm', 'same:password'],
'password_confirm' => ['required_without:password_confirmation', 'same:password'],
'user_id' => ['required', 'integer'],
'token' => ['required', 'string'],
], [
@@ -108,4 +114,23 @@ class AuthorizedUserInviteController extends Controller
return response()->json(['message' => 'Password has been successfully set.']);
}
private function isTrustedRedirectUrl(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return false;
}
$trusted = [
parse_url((string) config('app.url'), PHP_URL_HOST),
parse_url((string) config('app.frontend_url'), PHP_URL_HOST),
parse_url((string) config('services.frontend.url'), PHP_URL_HOST),
];
return in_array(strtolower($host), array_filter(array_map(
static fn ($value) => is_string($value) ? strtolower($value) : null,
$trusted
)), true);
}
}
@@ -58,14 +58,17 @@ class AuthorizedUsersController extends BaseApiController
return $guard;
}
$email = strtolower($request->validated('email'));
$email = strtolower(trim((string) $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);
$message = 'If the email belongs to a registered user, a confirmation email has been sent.';
return $this->success(['message' => $message], $message, Response::HTTP_OK);
}
$message = 'Authorized user added. A confirmation email has been sent.';
return $this->success(
['message' => $message, 'id' => $result['record']->id],
$message,
@@ -89,11 +92,18 @@ class AuthorizedUsersController extends BaseApiController
$row = $resolution;
$email = $request->validated('email') ?? null;
if (is_string($email) && $email !== '') {
$row->email = strtolower($email);
}
if (is_string($email) && trim($email) !== '') {
try {
$this->svc->changeEmailAndReinvite($row, $email);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
$row->save();
return $this->success(
['message' => 'Authorized user email updated. A new confirmation email has been sent.'],
'Authorized user email updated. A new confirmation email has been sent.'
);
}
return $this->success(['message' => 'Authorized user information updated.'], 'Authorized user information updated.');
}
@@ -127,15 +137,15 @@ class AuthorizedUsersController extends BaseApiController
private function authorizedUserForParent(int $id, int $parentId): AuthorizedUser|JsonResponse
{
$row = AuthorizedUser::query()->find($id);
$row = AuthorizedUser::query()
->where('id', $id)
->where('user_id', $parentId)
->first();
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;
}
}
@@ -148,10 +148,14 @@ class ParentController extends BaseApiController
$payload['emergency_contacts'] ?? []
);
$createdCount = count($result['created_student_ids']);
$skipped = $result['skipped'] ?? [];
return response()->json([
'ok' => true,
'created_student_ids' => $result['created_student_ids'],
], 201);
'skipped' => $skipped,
], $createdCount > 0 ? 201 : 200);
}
public function updateStudent(ParentStudentUpdateRequest $request, int $studentId): JsonResponse
@@ -283,10 +287,21 @@ class ParentController extends BaseApiController
}
/**
* @return int|JsonResponse Authenticated parent user id, or 401 JSON for this controller's legacy `{ ok }` shape.
* @return int|JsonResponse Effective primary-parent user id, or 401 JSON for this controller's legacy `{ ok }` shape.
*
* `parent.access` middleware resolves second parents (user_type=secondary)
* and authorized users (user_type=tertiary) to their primary parent and
* stashes the result as `primary_parent_id` on the request. If we are
* called outside that middleware we fall back to the authenticated id.
*/
private function parentIdOrUnauthorized(): int|JsonResponse
{
$request = request();
$primary = (int) ($request?->attributes?->get('primary_parent_id') ?? 0);
if ($primary > 0) {
return $primary;
}
$parentId = (int) (auth()->id() ?? 0);
if ($parentId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Api\Parents;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
use App\Http\Requests\Promotions\ParentPromotionOverviewRequest;
use App\Http\Resources\Promotions\StudentPromotionResource;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use App\Services\Promotions\PromotionEnrollmentService;
use App\Services\Promotions\PromotionQueryService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Parent portal endpoints for the promotion + enrollment workflow
* (plan section 12). The parent sees the promoted level and can step
* through the enrollment checklist defined in plan section 8.
*/
class ParentPromotionController extends BaseApiController
{
public function __construct(
private PromotionEnrollmentService $service,
private PromotionQueryService $query
) {
parent::__construct();
}
public function overview(ParentPromotionOverviewRequest $request): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = $this->service->overviewForParent(
$guard,
$request->validated()['next_school_year'] ?? null
);
return response()->json([
'ok' => true,
'records' => $data['records'],
'next_school_year' => $data['next_school_year'],
'message' => $data['message'],
]);
}
public function show(int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($record)),
]);
}
public function start(int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
try {
$updated = $this->service->startEnrollment($record, $guard, $guard);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function updateSteps(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
try {
$updated = $this->service->updateSteps($record, $guard, $request->steps(), $guard);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function submit(int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
try {
$updated = $this->service->submitEnrollment($record, $guard, $guard);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
private function loadOwnedRecord(int $promotionId, int $parentId): StudentPromotionRecord|JsonResponse
{
$record = StudentPromotionRecord::query()->find($promotionId);
if (!$record) {
return response()->json(['ok' => false, 'message' => 'Promotion record not found.'], Response::HTTP_NOT_FOUND);
}
$owns = (int) $record->parent_id === $parentId;
if (!$owns) {
$studentParentId = (int) (Student::query()
->where('id', $record->student_id)
->value('parent_id') ?? 0);
$owns = $studentParentId === $parentId;
}
if (!$owns) {
return response()->json(['ok' => false, 'message' => 'You are not authorised to view this promotion record.'], Response::HTTP_FORBIDDEN);
}
return $record;
}
private function parentIdOrUnauthorized(): int|JsonResponse
{
$request = request();
$primary = (int) ($request?->attributes?->get('primary_parent_id') ?? 0);
if ($primary > 0) {
return $primary;
}
$parentId = (int) (auth()->id() ?? 0);
if ($parentId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], Response::HTTP_UNAUTHORIZED);
}
return $parentId;
}
}