update api and add more features
This commit is contained in:
@@ -27,6 +27,9 @@ class AuthorizedUsersManagementService
|
||||
|
||||
/**
|
||||
* CI create() first step — lookup by token after invite email (pending row, created within TTL).
|
||||
*
|
||||
* Tokens are stored as SHA-256 hashes; we only ever look up by hash so that
|
||||
* a leaked database row cannot be replayed as a plaintext token.
|
||||
*/
|
||||
public function findPendingByIncomingToken(string $plainToken): ?AuthorizedUser
|
||||
{
|
||||
@@ -34,12 +37,8 @@ class AuthorizedUsersManagementService
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = $this->hashToken($plainToken);
|
||||
|
||||
return AuthorizedUser::query()
|
||||
->where(function ($q) use ($hash, $plainToken) {
|
||||
$q->where('token', $hash)->orWhere('token', $plainToken);
|
||||
})
|
||||
->where('token', $this->hashToken($plainToken))
|
||||
->where('created_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
|
||||
->first();
|
||||
}
|
||||
@@ -49,16 +48,12 @@ class AuthorizedUsersManagementService
|
||||
*/
|
||||
public function findActiveSetupRow(int $authorizedUserId, string $plainToken): ?AuthorizedUser
|
||||
{
|
||||
if ($plainToken === '') {
|
||||
if ($plainToken === '' || $authorizedUserId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = $this->hashToken($plainToken);
|
||||
|
||||
return AuthorizedUser::query()
|
||||
->where(function ($q) use ($hash, $plainToken) {
|
||||
$q->where('token', $hash)->orWhere('token', $plainToken);
|
||||
})
|
||||
->where('token', $this->hashToken($plainToken))
|
||||
->where('authorized_user_id', $authorizedUserId)
|
||||
->where('status', 'Active')
|
||||
->where('updated_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
|
||||
@@ -90,6 +85,9 @@ class AuthorizedUsersManagementService
|
||||
/**
|
||||
* CI `create` — when email is not a registered user, respond success without leaking (privacy).
|
||||
*
|
||||
* Refuses self-invites and silently re-uses an existing invite row to avoid
|
||||
* letting a parent spam another user with confirmation emails.
|
||||
*
|
||||
* @return array{created: bool, record: AuthorizedUser|null}
|
||||
*/
|
||||
public function inviteByEmail(int $primaryUserId, string $email): array
|
||||
@@ -104,22 +102,49 @@ class AuthorizedUsersManagementService
|
||||
return ['created' => false, 'record' => null];
|
||||
}
|
||||
|
||||
// A parent cannot invite themselves as their own authorized user.
|
||||
if ((int) $user->id === $primaryUserId) {
|
||||
return ['created' => false, 'record' => null];
|
||||
}
|
||||
|
||||
// If the same parent already invited this user, rotate the token instead
|
||||
// of creating a duplicate row (prevents spam + race-driven duplicates).
|
||||
$existing = AuthorizedUser::query()
|
||||
->where('user_id', $primaryUserId)
|
||||
->where('authorized_user_id', (int) $user->id)
|
||||
->first();
|
||||
|
||||
$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',
|
||||
]);
|
||||
$gender = trim((string) ($user->gender ?? ''));
|
||||
|
||||
if ($existing) {
|
||||
$existing->update([
|
||||
'firstname' => (string) ($user->firstname ?? ''),
|
||||
'lastname' => (string) ($user->lastname ?? ''),
|
||||
'phone_number' => $phone !== '' ? $phone : '-',
|
||||
'gender' => $gender !== '' ? $gender : '-',
|
||||
'email' => $email,
|
||||
'token' => $tokenHash,
|
||||
'status' => 'Pending',
|
||||
]);
|
||||
$record = $existing;
|
||||
} else {
|
||||
$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' => $gender !== '' ? $gender : '-',
|
||||
'email' => $email,
|
||||
'relation_to_user' => null,
|
||||
'token' => $tokenHash,
|
||||
'status' => 'Pending',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->sendConfirmationEmail($email, $plain);
|
||||
|
||||
|
||||
@@ -107,10 +107,34 @@ class ParentEnrollmentService
|
||||
$schoolYear = $context['school_year'];
|
||||
$semester = $context['semester'];
|
||||
|
||||
// Authorize every submitted student id against this parent up-front to
|
||||
// prevent IDOR — a parent must not be able to enroll or withdraw
|
||||
// another family's students by passing arbitrary ids.
|
||||
$requestedIds = array_values(array_unique(array_map(
|
||||
'intval',
|
||||
array_merge($enrollIds, $withdrawIds)
|
||||
)));
|
||||
$requestedIds = array_values(array_filter($requestedIds, static fn ($id) => $id > 0));
|
||||
|
||||
$ownedIds = $requestedIds === []
|
||||
? []
|
||||
: Student::query()
|
||||
->where('parent_id', $parentId)
|
||||
->whereIn('id', $requestedIds)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
$ownedSet = array_flip($ownedIds);
|
||||
|
||||
$enrolled = [];
|
||||
$withdrawn = [];
|
||||
|
||||
foreach ($enrollIds as $studentId) {
|
||||
$studentId = (int) $studentId;
|
||||
if (! isset($ownedSet[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Enrollment::query()
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
@@ -140,12 +164,18 @@ class ParentEnrollmentService
|
||||
]);
|
||||
}
|
||||
|
||||
$enrolled[] = (int) $studentId;
|
||||
$enrolled[] = $studentId;
|
||||
}
|
||||
|
||||
foreach ($withdrawIds as $studentId) {
|
||||
$studentId = (int) $studentId;
|
||||
if (! isset($ownedSet[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$enrollment = Enrollment::query()
|
||||
->where('student_id', $studentId)
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('is_withdrawn', 0)
|
||||
@@ -195,7 +225,7 @@ class ParentEnrollmentService
|
||||
}
|
||||
}
|
||||
|
||||
$withdrawn[] = (int) $studentId;
|
||||
$withdrawn[] = $studentId;
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services\Parents;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\Student;
|
||||
use App\Services\Invoices\InvoiceGenerationService;
|
||||
|
||||
class ParentEventParticipationService
|
||||
@@ -48,15 +49,61 @@ class ParentEventParticipationService
|
||||
$schoolYear = $context['school_year'];
|
||||
$semester = $context['semester'];
|
||||
|
||||
// First pass: parse + validate every `student_id:event_id` key and
|
||||
// collect the student ids that this parent actually owns. This stops
|
||||
// a parent from creating or deleting charges on another family's
|
||||
// students by manipulating the participation keys.
|
||||
$parsed = [];
|
||||
$candidateStudentIds = [];
|
||||
foreach ($participations as $key => $value) {
|
||||
[$studentId, $eventId] = array_map('intval', explode(':', (string) $key));
|
||||
$parts = explode(':', (string) $key, 2);
|
||||
if (count($parts) !== 2 || ! ctype_digit($parts[0]) || ! ctype_digit($parts[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentId = (int) $parts[0];
|
||||
$eventId = (int) $parts[1];
|
||||
if ($studentId <= 0 || $eventId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
if (! in_array($normalized, ['yes', 'no'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed[] = [
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'value' => $normalized,
|
||||
];
|
||||
$candidateStudentIds[$studentId] = true;
|
||||
}
|
||||
|
||||
if ($parsed === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownedIds = Student::query()
|
||||
->where('parent_id', $parentId)
|
||||
->whereIn('id', array_keys($candidateStudentIds))
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
$ownedSet = array_flip($ownedIds);
|
||||
|
||||
foreach ($parsed as $row) {
|
||||
if (! isset($ownedSet[$row['student_id']])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = EventCharges::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('student_id', $studentId)
|
||||
->where('event_id', $eventId)
|
||||
->where('student_id', $row['student_id'])
|
||||
->where('event_id', $row['event_id'])
|
||||
->first();
|
||||
|
||||
if ($value === 'no') {
|
||||
if ($row['value'] === 'no') {
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
}
|
||||
@@ -64,14 +111,14 @@ class ParentEventParticipationService
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(['participation' => $value]);
|
||||
$existing->update(['participation' => $row['value']]);
|
||||
} else {
|
||||
$event = Event::getEvent($eventId, $schoolYear);
|
||||
$event = Event::getEvent($row['event_id'], $schoolYear);
|
||||
EventCharges::query()->create([
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'participation' => $value,
|
||||
'student_id' => $row['student_id'],
|
||||
'event_id' => $row['event_id'],
|
||||
'participation' => $row['value'],
|
||||
'charged' => $event['amount'] ?? 0,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
|
||||
@@ -74,6 +74,7 @@ class ParentRegistrationService
|
||||
}
|
||||
|
||||
$createdStudentIds = [];
|
||||
$skippedStudents = [];
|
||||
|
||||
DB::transaction(function () use (
|
||||
$parentId,
|
||||
@@ -81,17 +82,28 @@ class ParentRegistrationService
|
||||
$emergencyContacts,
|
||||
$schoolYear,
|
||||
$semester,
|
||||
&$createdStudentIds
|
||||
&$createdStudentIds,
|
||||
&$skippedStudents
|
||||
) {
|
||||
foreach ($students as $student) {
|
||||
// Duplicate check is scoped to THIS parent: one family's
|
||||
// legitimately registered child must not block another family
|
||||
// from registering a child with the same name and DOB.
|
||||
$exists = Student::query()
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('dob', $student['dob'])
|
||||
->where('firstname', $student['firstname'])
|
||||
->where('lastname', $student['lastname'])
|
||||
->whereRaw('LOWER(TRIM(firstname)) = ?', [strtolower(trim((string) $student['firstname']))])
|
||||
->whereRaw('LOWER(TRIM(lastname)) = ?', [strtolower(trim((string) $student['lastname']))])
|
||||
->first();
|
||||
|
||||
if ($exists) {
|
||||
$skippedStudents[] = [
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'dob' => $student['dob'],
|
||||
'reason' => 'already_registered',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -153,6 +165,7 @@ class ParentRegistrationService
|
||||
|
||||
return [
|
||||
'created_student_ids' => $createdStudentIds,
|
||||
'skipped' => $skippedStudents,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user