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
@@ -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;
}
}