Compare commits

...

3 Commits

Author SHA1 Message Date
root 140be9922d fix registration issue and split parent controller into services
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m32s
2026-08-30 20:40:37 -04:00
root 361d0c0d3a Add canonical user access profile table
Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups.

Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, removed, or replaced.

Create a default access profile whenever a user account is created, then refresh it when roles are assigned. Delete the access profile when a user is deleted so the table does not keep orphaned authorization rows.

Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
2026-08-30 20:40:37 -04:00
root 0f8ad86b4f Add canonical user access profile table
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m20s
Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups.

Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, or deleted.

Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
2026-08-29 23:20:40 -04:00
15 changed files with 2917 additions and 1314 deletions
+64 -6
View File
@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Models\UserAccessProfileModel;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -212,6 +213,7 @@ class AuthController extends BaseController
// Fetch roles
$roleNames = $this->getUserRoleNames((int) $user['id']);
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
// Build roles map (object with keys per example)
$rolesMap = [];
@@ -248,6 +250,10 @@ class AuthController extends BaseController
'id' => (int) $user['id'],
'name' => $payload['name'],
'roles' => (object) $rolesMap,
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
],
]);
}
@@ -274,6 +280,10 @@ class AuthController extends BaseController
'type' => session()->get('user_type'),
'roles' => $roles,
'role' => $activeRole,
'primary_category' => session()->get('primary_category'),
'is_admin' => (bool) session()->get('is_admin'),
'is_teacher' => (bool) session()->get('is_teacher'),
'is_parent' => (bool) session()->get('is_parent'),
],
]);
}
@@ -371,6 +381,7 @@ class AuthController extends BaseController
'iat' => $now,
'exp' => $exp,
];
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
@@ -384,6 +395,10 @@ class AuthController extends BaseController
'name' => $payload['name'],
'email' => $userData['email'],
'roles' => $payload['roles'],
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
],
]);
} catch (\Exception $e) {
@@ -477,11 +492,17 @@ class AuthController extends BaseController
protected function getUserRoleNames(int $userId): array
{
$userRoleModel = new UserRoleModel();
$rolesRows = $userRoleModel->select('roles.name')
$db = \Config\Database::connect();
$builder = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->get()
->getResultArray();
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$builder->where('user_roles.deleted_at', null);
}
$rolesRows = $builder->get()->getResultArray();
return array_column($rolesRows, 'name');
}
@@ -565,11 +586,17 @@ class AuthController extends BaseController
private function loginUser($user, ?string $redirectTo = null)
{
$userRoleModel = new UserRoleModel();
$roles = $userRoleModel->select('roles.name')
$db = \Config\Database::connect();
$rolesBuilder = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $user['id'])
->get()
->getResultArray();
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$rolesBuilder->where('user_roles.deleted_at', null);
}
$roles = $rolesBuilder->get()->getResultArray();
if (empty($roles)) {
log_message('error', 'No roles found for user ID: ' . $user['id']);
@@ -577,6 +604,7 @@ class AuthController extends BaseController
}
$roleNames = array_column($roles, 'name');
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
session()->regenerate(true);
session()->set([
@@ -588,6 +616,10 @@ class AuthController extends BaseController
'login_time' => time(),
'last_activity' => time(),
'roles' => $roleNames,
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
@@ -612,6 +644,32 @@ class AuthController extends BaseController
}
private function accessProfileForUser(int $userId, array $roleNames): array
{
try {
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
if ($profile !== null) {
return [
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
'is_admin' => (bool) ($profile['is_admin'] ?? false),
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
'is_parent' => (bool) ($profile['is_parent'] ?? false),
];
}
} catch (\Throwable $e) {
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
}
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
return [
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
'is_admin' => (bool) $flags['is_admin'],
'is_teacher' => (bool) $flags['is_teacher'],
'is_parent' => (bool) $flags['is_parent'],
];
}
private function applyStylePreferences(int $userId): void
{
if (!$userId) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class CreateUserAccessProfiles extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('user_access_profiles')) {
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'user_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'primary_category' => [
'type' => 'VARCHAR',
'constraint' => 20,
'default' => 'guest',
],
'is_admin' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'is_teacher' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'is_parent' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'role_names' => [
'type' => 'TEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('user_id', false, true);
$this->forge->addKey('primary_category');
$this->forge->addKey('is_admin');
$this->forge->addKey('is_teacher');
$this->forge->addKey('is_parent');
$this->forge->createTable('user_access_profiles', true);
}
$this->backfillProfiles();
}
public function down(): void
{
$this->forge->dropTable('user_access_profiles', true);
}
private function backfillProfiles(): void
{
if (
! $this->db->tableExists('users')
|| ! $this->db->tableExists('roles')
|| ! $this->db->tableExists('user_roles')
|| ! $this->db->tableExists('user_access_profiles')
) {
return;
}
$now = date('Y-m-d H:i:s');
$deletedFilter = $this->db->fieldExists('deleted_at', 'user_roles')
? 'AND ur.deleted_at IS NULL'
: '';
$sql = "
INSERT INTO user_access_profiles (
user_id,
primary_category,
is_admin,
is_teacher,
is_parent,
role_names,
created_at,
updated_at
)
SELECT
u.id AS user_id,
CASE
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'admin'
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'teacher'
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) = 1 THEN 'parent'
ELSE 'guest'
END AS primary_category,
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_admin,
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_teacher,
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) AS is_parent,
GROUP_CONCAT(DISTINCT r.name ORDER BY COALESCE(r.priority, 999), r.name SEPARATOR ', ') AS role_names,
? AS created_at,
? AS updated_at
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id {$deletedFilter}
LEFT JOIN roles r ON r.id = ur.role_id AND COALESCE(r.is_active, 1) = 1
GROUP BY u.id
ON DUPLICATE KEY UPDATE
primary_category = VALUES(primary_category),
is_admin = VALUES(is_admin),
is_teacher = VALUES(is_teacher),
is_parent = VALUES(is_parent),
role_names = VALUES(role_names),
updated_at = VALUES(updated_at)
";
$this->db->query($sql, [$now, $now]);
}
}
-12
View File
@@ -492,18 +492,6 @@ class StudentModel extends Model
(
student_class.student_id IS NOT NULL
OR enrollments.student_id IS NOT NULL
OR (
NOT EXISTS (
SELECT 1
FROM student_class sc_history
WHERE sc_history.student_id = students.id
)
AND NOT EXISTS (
SELECT 1
FROM enrollments e_history
WHERE e_history.student_id = students.id
)
)
)
";
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class UserAccessProfileModel extends Model
{
public const CATEGORY_ADMIN = 'admin';
public const CATEGORY_TEACHER = 'teacher';
public const CATEGORY_PARENT = 'parent';
public const CATEGORY_GUEST = 'guest';
private const TEACHER_ROLE_TOKENS = ['teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
private const NON_ADMIN_ROLE_TOKENS = ['guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
protected $table = 'user_access_profiles';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = [
'user_id',
'primary_category',
'is_admin',
'is_teacher',
'is_parent',
'role_names',
'created_at',
'updated_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
public function syncUser(int $userId): ?array
{
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
return null;
}
$roles = $this->rolesForUser($userId);
$flags = self::flagsForRoles($roles);
$now = utc_now();
$data = [
'user_id' => $userId,
'primary_category' => self::primaryCategory($flags),
'is_admin' => $flags['is_admin'] ? 1 : 0,
'is_teacher' => $flags['is_teacher'] ? 1 : 0,
'is_parent' => $flags['is_parent'] ? 1 : 0,
'role_names' => implode(', ', array_values(array_unique(array_map(
static fn (array $role): string => (string) ($role['name'] ?? ''),
$roles
)))),
'updated_at' => $now,
];
$existing = $this->where('user_id', $userId)->first();
if ($existing) {
$this->update((int) $existing['id'], $data);
return $this->find((int) $existing['id']);
}
$data['created_at'] = $now;
$id = $this->insert($data);
return $id ? $this->find((int) $id) : null;
}
public function syncAll(): void
{
if (! $this->db->tableExists('users') || ! $this->db->tableExists($this->table)) {
return;
}
$rows = $this->db->table('users')->select('id')->get()->getResultArray();
foreach ($rows as $row) {
$this->syncUser((int) ($row['id'] ?? 0));
}
}
public function getForUser(int $userId): ?array
{
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
return null;
}
$profile = $this->where('user_id', $userId)->first();
return $profile ?: $this->syncUser($userId);
}
public function getUsersByCategory(string $category): array
{
$field = match (self::normalizeRoleToken($category)) {
self::CATEGORY_ADMIN => 'is_admin',
self::CATEGORY_TEACHER => 'is_teacher',
self::CATEGORY_PARENT => 'is_parent',
default => null,
};
if ($field === null || ! $this->db->tableExists($this->table)) {
return [];
}
return $this->select('users.*, user_access_profiles.primary_category, user_access_profiles.role_names')
->join('users', 'users.id = user_access_profiles.user_id', 'inner')
->where($field, 1)
->orderBy('users.lastname', 'ASC')
->orderBy('users.firstname', 'ASC')
->findAll();
}
public static function flagsForRoles(array $roles): array
{
$tokens = [];
foreach ($roles as $role) {
if (is_array($role)) {
$tokens[] = self::normalizeRoleToken((string) ($role['slug'] ?? ''));
$tokens[] = self::normalizeRoleToken((string) ($role['name'] ?? ''));
continue;
}
$tokens[] = self::normalizeRoleToken((string) $role);
}
$tokens = array_values(array_unique(array_filter($tokens)));
$isParent = in_array('parent', $tokens, true);
$isTeacher = count(array_intersect($tokens, array_map([self::class, 'normalizeRoleToken'], self::TEACHER_ROLE_TOKENS))) > 0;
$isAdmin = false;
foreach ($tokens as $token) {
if (! in_array($token, array_map([self::class, 'normalizeRoleToken'], self::NON_ADMIN_ROLE_TOKENS), true)) {
$isAdmin = true;
break;
}
}
return [
'is_admin' => $isAdmin,
'is_teacher' => $isTeacher,
'is_parent' => $isParent,
];
}
public static function primaryCategory(array $flags): string
{
if (! empty($flags['is_admin'])) {
return self::CATEGORY_ADMIN;
}
if (! empty($flags['is_teacher'])) {
return self::CATEGORY_TEACHER;
}
if (! empty($flags['is_parent'])) {
return self::CATEGORY_PARENT;
}
return self::CATEGORY_GUEST;
}
private function rolesForUser(int $userId): array
{
if (! $this->db->tableExists('user_roles') || ! $this->db->tableExists('roles')) {
return [];
}
$builder = $this->db->table('user_roles ur')
->select('r.name, r.slug')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('ur.user_id', $userId)
->where('COALESCE(r.is_active, 1) = 1', null, false);
if ($this->db->fieldExists('deleted_at', 'user_roles')) {
$builder->where('ur.deleted_at', null);
}
return $builder->get()->getResultArray();
}
private static function normalizeRoleToken(string $value): string
{
$value = strtolower(trim($value));
return str_replace([' ', '-'], '_', $value);
}
}
+30
View File
@@ -39,6 +39,36 @@ class UserModel extends Model
protected $useTimestamps = true; // Enable automatic timestamps
protected $createdField = 'created_at'; // Define the field name for the created timestamp
protected $updatedField = 'updated_at'; // Define the field name for the updated timestamp
protected $afterInsert = ['syncAccessProfileAfterInsert'];
protected $afterDelete = ['deleteAccessProfileAfterDelete'];
protected function syncAccessProfileAfterInsert(array $data): array
{
try {
$userId = (int) ($data['id'] ?? 0);
if ($userId > 0) {
model(UserAccessProfileModel::class)->syncUser($userId);
}
} catch (\Throwable $e) {
log_message('error', 'UserModel access profile sync failed: ' . $e->getMessage());
}
return $data;
}
protected function deleteAccessProfileAfterDelete(array $data): array
{
try {
$ids = array_filter(array_map('intval', (array) ($data['id'] ?? [])));
if ($ids !== [] && $this->db->tableExists('user_access_profiles')) {
$this->db->table('user_access_profiles')->whereIn('user_id', $ids)->delete();
}
} catch (\Throwable $e) {
log_message('error', 'UserModel access profile cleanup failed: ' . $e->getMessage());
}
return $data;
}
// Existing methods remain unchanged
+4 -2
View File
@@ -33,9 +33,10 @@ class UserRoleModel extends Model
$userId = (int) ($data['data']['user_id'] ?? 0);
if ($userId > 0) {
service('staffDirectorySync')->syncUser($userId);
model(UserAccessProfileModel::class)->syncUser($userId);
}
} catch (\Throwable $e) {
log_message('error', 'UserRoleModel staff directory sync failed: ' . $e->getMessage());
log_message('error', 'UserRoleModel role-derived sync failed: ' . $e->getMessage());
}
return $data;
@@ -45,8 +46,9 @@ class UserRoleModel extends Model
{
try {
service('staffDirectorySync')->syncAll();
model(UserAccessProfileModel::class)->syncAll();
} catch (\Throwable $e) {
log_message('error', 'UserRoleModel staff directory delete sync failed: ' . $e->getMessage());
log_message('error', 'UserRoleModel role-derived delete sync failed: ' . $e->getMessage());
}
return $data;
@@ -0,0 +1,186 @@
<?php
namespace App\Services\Parents;
use App\Controllers\View\EmailController;
use App\Models\AuthorizedUserModel;
use App\Models\UserModel;
use App\Services\SchoolIdService;
use CodeIgniter\Database\BaseConnection;
use Exception;
class ParentAccountService
{
public function __construct(
private readonly BaseConnection $db,
private readonly UserModel $userModel,
private readonly AuthorizedUserModel $authorizedUsersModel,
) {
}
public function canAccessUserRecord(int $requestedUserId, int $sessionUserId, array $sessionRoles): bool
{
if ($sessionUserId <= 0 || $requestedUserId <= 0) {
return false;
}
if ($sessionUserId === $requestedUserId) {
return true;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter($sessionRoles)
);
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
}
public function isEmailUnique(string $email): bool
{
foreach (['users' => 'email', 'emergency_contacts' => 'email'] as $table => $column) {
if ($this->db->table($table)->where($column, $email)->countAllResults() > 0) {
return false;
}
}
return true;
}
public function createRelatedUser(array $userData, string $relationToStudent, string $semester, string $schoolYear): int|false
{
$schoolIdService = new SchoolIdService();
$token = bin2hex(random_bytes(48));
$tokenHash = hash('sha256', $token);
$userType = in_array(strtolower($relationToStudent), ['wife', 'husband'], true) ? 'Secondary' : 'Tertiary';
$validation = \Config\Services::validation();
$validation->setRules([
'firstname' => [
'label' => 'First Name',
'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
'errors' => ['regex_match' => 'First name may only contain letters, spaces, and dashes.'],
],
'lastname' => [
'label' => 'Last Name',
'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
'errors' => ['regex_match' => 'Last name may only contain letters, spaces, and dashes.'],
],
'email' => [
'label' => 'Email Address',
'rules' => 'required|valid_email|max_length[150]|is_unique[users.email]',
'errors' => ['is_unique' => 'This email is already registered.'],
],
'cellphone' => [
'label' => 'Cell Phone',
'rules' => 'required|regex_match[/^\d{10}$/]',
'errors' => ['regex_match' => 'Phone number must be exactly 10 digits.'],
],
'gender' => 'required|in_list[Male,Female]',
'city' => 'required|max_length[100]',
'state' => 'required|max_length[100]',
'zip' => 'required|regex_match[/^\d{5}$/]',
]);
if (! $validation->run($userData)) {
log_message('error', 'User creation failed due to invalid data: ' . json_encode($validation->getErrors()));
return false;
}
$userEntry = [
'firstname' => ucfirst(strtolower($userData['firstname'])),
'lastname' => ucfirst(strtolower($userData['lastname'])),
'gender' => $userData['gender'],
'cellphone' => $userData['cellphone'],
'email' => strtolower($userData['email']),
'address_street' => $userData['address_street'] ?? '',
'apt' => $userData['apt'] ?? null,
'city' => ucfirst(strtolower($userData['city'])),
'state' => strtoupper($userData['state']),
'zip' => $userData['zip'],
'accept_school_policy' => $userData['accept_school_policy'] ?? 0,
'token' => $tokenHash,
'is_verified' => 0,
'status' => 'Inactive',
'user_type' => $userType,
'semester' => $semester,
'school_year' => $schoolYear,
'school_id' => $schoolIdService->generateUserSchoolId(),
];
try {
if (! $this->userModel->insert($userEntry)) {
log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true));
return false;
}
$userId = (int) $this->userModel->getInsertID();
$this->sendActivationEmail((string) $userData['email'], $token);
log_message('info', "User with ID $userId created successfully and activation email sent.");
return $userId;
} catch (Exception $e) {
log_message('error', 'Exception during user creation: ' . $e->getMessage());
return false;
}
}
public function updateAuthorizedUsers(int $userId, array $data): void
{
$validation = \Config\Services::validation();
$validation->setRules([
'email' => [
'label' => 'Email',
'rules' => 'required|valid_email|max_length[150]',
'errors' => [
'required' => 'Email is required.',
'valid_email' => 'Please provide a valid email address.',
'max_length' => 'Email must be less than 150 characters.',
],
],
'name' => [
'label' => 'Name',
'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
'errors' => [
'required' => 'Name is required.',
'regex_match' => 'Name can only contain letters, spaces, and dashes.',
'min_length' => 'Name must be at least 3 characters long.',
'max_length' => 'Name must be less than 100 characters.',
],
],
]);
if (! $validation->run($data)) {
log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors()));
return;
}
$existingAuthorizedUser = $this->authorizedUsersModel
->where('user_id', $userId)
->where('email', $data['email'])
->first();
if ($existingAuthorizedUser) {
$this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data);
return;
}
$data['user_id'] = $userId;
$data['status'] = 'Pending';
$this->authorizedUsersModel->insert($data);
}
private function sendActivationEmail(string $email, string $token): void
{
$emailController = new EmailController();
$subject = 'Activate Your Account';
$activationLink = site_url('/user/confirm/' . $token);
$message = "Please click the following link to confirm your email and set your password: $activationLink";
if ($emailController->sendEmail($email, $subject, $message)) {
log_message('info', 'Activation email sent successfully to ' . $email);
} else {
log_message('error', 'Failed to send activation email to ' . $email);
}
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Parents;
use CodeIgniter\Database\BaseConnection;
class ParentAttendanceService
{
public function __construct(private readonly BaseConnection $db)
{
}
public function attendanceForParent(int $parentId, string $schoolYear): array
{
if ($parentId <= 0 || $schoolYear === '') {
return [];
}
return $this->db->table('attendance_data')
->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason')
->join('students', 'students.id = attendance_data.student_id')
->where('attendance_data.school_year', $schoolYear)
->where('students.parent_id', $parentId)
->get()
->getResultArray();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
<?php
namespace App\Services\Parents;
use App\Controllers\View\InvoiceController;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\EventModel;
class ParentEventParticipationService
{
public function __construct(
private readonly EventChargesModel $chargesModel,
private readonly EventModel $eventModel,
private readonly EnrollmentModel $enrollmentModel,
private readonly InvoiceController $invoiceController,
) {
}
public function pageData(int $parentId, string $schoolYear, string $semester): array
{
$activeEvents = $this->eventModel->getActiveEvents($schoolYear, $semester);
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
$charges = [];
$externalParticipantsByEvent = [];
foreach ($chargesList as $charge) {
$studentId = $charge['student_id'] ?? null;
$eventId = (int) ($charge['event_id'] ?? 0);
if (! empty($studentId)) {
$charges[$studentId . ':' . $eventId] = [
'participation' => $charge['participation'],
'date' => $charge['updated_at'] ?? $charge['created_at'],
];
continue;
}
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
if ($eventId > 0 && $externalName !== '') {
$externalParticipantsByEvent[$eventId][] = [
'name' => $externalName,
'note' => (string) ($charge['external_note'] ?? ''),
'participation' => (string) ($charge['participation'] ?? ''),
'event_paid' => ! empty($charge['event_paid']),
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
];
}
}
return [
'activeEvents' => $activeEvents,
'charges' => $charges,
'externalParticipantsByEvent' => $externalParticipantsByEvent,
'yourStudents' => $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear),
'activeEventCount' => is_array($activeEvents) ? count($activeEvents) : 0,
];
}
public function updateParticipation(array $participations, int $parentId, string $schoolYear, string $semester): void
{
foreach ($participations as $key => $value) {
[$studentId, $eventId] = explode(':', (string) $key);
$existing = $this->chargesModel->where([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
])->first();
if ($value === 'no') {
if ($existing) {
$this->chargesModel->delete($existing['id']);
}
continue;
}
if ($existing) {
$this->chargesModel->update($existing['id'], ['participation' => $value]);
continue;
}
$event = $this->eventModel->getEvent($eventId, $schoolYear);
$this->chargesModel->insert([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => $value,
'charged' => $event['amount'],
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $parentId,
]);
}
$this->invoiceController->generateInvoice($parentId);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Services\Parents;
use CodeIgniter\Database\BaseConnection;
class ParentPaymentService
{
public function __construct(private readonly BaseConnection $db)
{
}
public function invoicesForParent(int $parentId, bool $includeRegisteredKids = false): array
{
if ($parentId <= 0) {
return [];
}
$select = $includeRegisteredKids ? 'invoices.*, registeredKids' : '*';
return $this->db->table('invoices')
->select($select)
->where('parent_id', $parentId)
->get()
->getResultArray();
}
public function markTuitionPaidForParent(int $parentId): void
{
if ($parentId <= 0) {
return;
}
$this->db->table('students')
->where('parent_id', $parentId)
->update(['tuition_paid' => 1]);
}
}
@@ -0,0 +1,724 @@
<?php
namespace App\Services\Parents;
use App\Models\EmergencyContactModel;
use App\Models\EnrollmentModel;
use App\Models\StudentAllergyModel;
use App\Models\StudentMedicalConditionModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use App\Services\PhoneFormatterService;
use App\Services\SchoolIdService;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\Exceptions\DatabaseException;
use DateTime;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use Throwable;
class ParentRegistrationService
{
public function __construct(
private readonly BaseConnection $db,
private readonly UserModel $userModel,
private readonly StudentModel $studentModel,
private readonly EnrollmentModel $enrollmentModel,
private readonly EmergencyContactModel $emergencyContactModel,
private readonly StudentMedicalConditionModel $medicalConditionModel,
private readonly StudentAllergyModel $allergyModel,
) {
}
public function registrationData(
int $parentId,
string $selectedSchoolYear,
bool $isEditable,
int $maxChilds,
int $maxEmergency
): array {
$enrollments = $this->getEnrollmentsByParent($parentId, $selectedSchoolYear);
$enrollmentMap = [];
foreach ($enrollments as $enroll) {
$enrollmentMap[$enroll['student_id']] = $enroll;
}
$user = $this->userModel->find($parentId);
if (! $user || ($user['user_type'] ?? '') !== 'primary') {
throw new \RuntimeException('Only primary parents are allowed to register children.');
}
$kids = $this->studentModel->where('parent_id', $parentId)->findAll();
foreach ($kids as &$kid) {
$studentId = (int) ($kid['id'] ?? 0);
$kid['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? [];
$kid['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? [];
$kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && ! empty($enrollmentMap[$studentId]['id']) ? 1 : 0;
}
unset($kid);
$this->ensureStudentYearStatusRows($kids, $selectedSchoolYear);
service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear);
foreach ($kids as &$kid) {
$kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId);
}
unset($kid);
return [
'existingKids' => $kids,
'emergencies' => $this->emergencyContactModel->where('parent_id', $parentId)->findAll(),
'parent' => $user,
'maxChilds' => $maxChilds,
'maxEmergency' => $maxEmergency,
'enrollments' => $enrollments,
'selectedYear' => $selectedSchoolYear,
'isEditable' => $isEditable,
];
}
public function validateRegistrationSubmission(array $post, array $registrationData): array
{
$existingKids = $registrationData['existingKids'] ?? [];
$existingECs = $registrationData['emergencies'] ?? [];
$maxChilds = (int) ($registrationData['maxChilds'] ?? 0);
$maxEmergency = (int) ($registrationData['maxEmergency'] ?? 0);
$incomingFirstNames = (array) ($post['studentFirstName'] ?? []);
$incomingLastNames = (array) ($post['studentLastName'] ?? []);
$incomingDOBs = (array) ($post['dob'] ?? []);
$newStudentCount = count(array_filter($incomingFirstNames));
foreach ($incomingFirstNames as $i => $firstName) {
$lastName = trim($incomingLastNames[$i] ?? '');
$dob = trim($incomingDOBs[$i] ?? '');
if (empty($firstName) || empty($lastName) || empty($dob)) {
continue;
}
foreach ($existingKids as $kid) {
if (
strtolower($kid['firstname']) === strtolower($firstName)
&& strtolower($kid['lastname']) === strtolower($lastName)
&& $kid['dob'] === $dob
) {
return ['ok' => false, 'error' => "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."];
}
}
}
$seenStudents = [];
foreach ($incomingFirstNames as $i => $firstName) {
$lastName = trim($incomingLastNames[$i] ?? '');
$dob = trim($incomingDOBs[$i] ?? '');
if (empty($firstName) || empty($lastName) || empty($dob)) {
continue;
}
$key = strtolower($firstName . '|' . $lastName . '|' . $dob);
if (isset($seenStudents[$key])) {
return ['ok' => false, 'error' => "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."];
}
$seenStudents[$key] = true;
}
$incomingECFirst = (array) ($post['emergency_firstname'] ?? []);
$incomingECLast = (array) ($post['emergency_lastname'] ?? []);
$incomingECPhones = (array) ($post['emergency_phone'] ?? []);
$incomingECEmails = (array) ($post['emergency_email'] ?? []);
$newECCount = count(array_filter($incomingECFirst));
foreach ($incomingECFirst as $i => $first) {
$last = trim($incomingECLast[$i] ?? '');
$phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? '');
$email = strtolower(trim($incomingECEmails[$i] ?? ''));
if (empty($first) || empty($last)) {
continue;
}
foreach ($existingECs as $contact) {
$existingPhone = preg_replace('/\D/', '', $contact['cellphone']);
$existingEmail = strtolower($contact['email']);
if (
strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last))
|| ($phone && $phone === $existingPhone)
|| ($email && $email === $existingEmail)
) {
return ['ok' => false, 'error' => "Duplicate emergency contact: {$first} {$last} already exists."];
}
}
}
$seenContacts = [];
foreach ($incomingECFirst as $i => $first) {
$last = trim($incomingECLast[$i] ?? '');
$phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? '');
$email = strtolower(trim($incomingECEmails[$i] ?? ''));
if (empty($first) || empty($last)) {
continue;
}
$key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email);
if (isset($seenContacts[$key])) {
return ['ok' => false, 'error' => "Duplicate emergency contact entry in the form: {$first} {$last}."];
}
$seenContacts[$key] = true;
}
$existingKidsCount = count($existingKids);
$existingECCount = count($existingECs);
if (($existingKidsCount + $newStudentCount) > $maxChilds) {
return ['ok' => false, 'error' => "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."];
}
if (($existingECCount + $newECCount) > $maxEmergency) {
return ['ok' => false, 'error' => "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."];
}
return ['ok' => true];
}
public function saveStudentAtIndex(
int $idx,
array $post,
int $parentId,
string $schoolYear,
SchoolIdService $schoolIdService,
?bool $isNew = null,
?int $studentId = null,
?string $schoolStartDate = null,
?string $ageDateReference = null
): array {
$firstName = $post['studentFirstName'][$idx] ?? null;
$lastName = $post['studentLastName'][$idx] ?? null;
$dob = $post['dob'][$idx] ?? null;
$gender = $post['gender'][$idx] ?? null;
$grade = $post['registration_grade'][$idx] ?? null;
$conditions = $post['medical_conditions'][$idx] ?? [];
$allergies = $post['allergies'][$idx] ?? [];
$photoRaw = $post['photo_consent'][$idx] ?? '';
if (! $firstName || ! $lastName || ! $dob || ! $gender || ! $grade) {
return ['ok' => false, 'empty' => true];
}
$firstName = $this->normalizeStudentName((string) $firstName);
$lastName = $this->normalizeStudentName((string) $lastName);
$this->validateNames($firstName);
$this->validateNames($lastName);
$dobObj = new DateTime((string) $dob);
$schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear, $schoolStartDate);
$age = $this->calculateAgeAsOfSchoolYearStartYear((string) $dob, $schoolYear);
$validation = $this->validateDobAge(
(string) $dob,
$this->registrationMinimumAgeDeadline($schoolYear, $ageDateReference),
5,
18,
$schoolYearAgeDeadline
);
if (! $validation['isValid']) {
$displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y');
return [
'ok' => false,
'error' => "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}.",
];
}
$studentData = [
'firstname' => $firstName,
'lastname' => $lastName,
'age' => $age,
'dob' => $dobObj->format('Y-m-d'),
'gender' => $gender,
'registration_grade' => $grade,
'photo_consent' => strtolower((string) $photoRaw) === 'yes' ? 1 : 0,
'parent_id' => $parentId,
'year_of_registration' => date('Y'),
];
if ($this->db->fieldExists('school_year', 'students')) {
$studentData['school_year'] = $schoolYear;
}
if ($isNew !== null) {
$studentData['is_new'] = $isNew ? 1 : 0;
}
$existingBuilder = $this->studentModel
->where('parent_id', $parentId)
->where('dob', $dobObj->format('Y-m-d'))
->where('firstname', $firstName)
->where('lastname', $lastName);
if ($this->db->fieldExists('school_year', 'students')) {
$existingBuilder->where('school_year', $schoolYear);
}
if (! $studentId && $existingBuilder->first()) {
return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."];
}
if ($studentId) {
$existing = $this->studentModel->find($studentId);
if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== $parentId) {
return ['ok' => false, 'error' => 'Student record was not found for this parent account.'];
}
$this->auditParentStudentFieldChanges($existing, $studentData, $parentId, 'parent_student_edit');
$this->studentModel->update($studentId, $studentData);
if ($this->parentEditAffectsEligibility($existing, $studentData)) {
$this->recheckEligibilityAfterParentEdit($studentId, $parentId, $schoolYear);
}
} else {
$studentData['registration_date'] = utc_now();
$studentData['tuition_paid'] = 0;
$studentData['school_id'] = $schoolIdService->generateStudentSchoolId();
try {
$studentId = (int) $this->studentModel->insert($studentData, true);
} catch (DatabaseException $e) {
if (strpos($e->getMessage(), '1062') !== false) {
return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."];
}
throw $e;
}
}
if ($isNew !== null && $studentId > 0) {
$studentYearStatus = service('studentYearStatus');
$statusSaved = $studentYearStatus->upsert($studentId, $schoolYear, $isNew);
if (! $statusSaved || ! $studentYearStatus->hasStatus($studentId, $schoolYear)) {
throw new \RuntimeException('Student year status could not be saved for student ID ' . $studentId . ' and school year ' . $schoolYear . '.');
}
}
$this->medicalConditionModel->where('student_id', $studentId)->delete();
foreach ((array) $conditions as $condition) {
$condition = trim((string) $condition);
if ($condition !== '') {
$this->medicalConditionModel->insert(['student_id' => $studentId, 'condition_name' => $condition]);
}
}
$this->allergyModel->where('student_id', $studentId)->delete();
foreach ((array) $allergies as $allergy) {
$allergy = trim((string) $allergy);
if ($allergy !== '') {
$this->allergyModel->insert(['student_id' => $studentId, 'allergy' => $allergy]);
}
}
return ['ok' => true, 'student_id' => $studentId];
}
public function saveEmergencyContact(int $parentId, array $post, ?array $single = null, ?int $id = null): array
{
$phoneFormatter = new PhoneFormatterService();
if ($single !== null) {
$firstName = $this->formatName($single['first_name'] ?? '');
$lastName = $this->formatName($single['last_name'] ?? '');
$relation = trim($single['relation'] ?? '');
$phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? '');
$email = strtolower(trim($single['email'] ?? ''));
if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') {
return ['ok' => true, 'empty' => true];
}
$this->validateNames($firstName);
$this->validateNames($lastName);
if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \Exception('Invalid email format for emergency contact.');
}
$data = [
'parent_id' => $parentId,
'emergency_contact_name' => $firstName . ' ' . $lastName,
'cellphone' => $phone,
'email' => $email,
'relation' => $relation,
'updated_at' => utc_now(),
];
$duplicateBuilder = $this->emergencyContactModel
->where('parent_id', $parentId)
->where('emergency_contact_name', $data['emergency_contact_name'])
->where('cellphone', $phone)
->where('email', $email)
->where('relation', $relation);
if ($id !== null) {
$duplicateBuilder->where('id !=', $id);
}
if ($duplicateBuilder->first()) {
return ['ok' => false, 'error' => $id !== null
? 'Another emergency contact with the same information already exists.'
: 'This emergency contact is already registered.'];
}
if ($id !== null) {
$this->emergencyContactModel->update($id, $data);
} else {
$this->emergencyContactModel->insert($data);
}
return ['ok' => true];
}
$firstNames = (array) ($post['emergency_firstname'] ?? []);
$lastNames = (array) ($post['emergency_lastname'] ?? []);
$relations = (array) ($post['emergency_relation'] ?? []);
$phones = (array) ($post['emergency_phone'] ?? []);
$emails = (array) ($post['emergency_email'] ?? []);
foreach ($firstNames as $idx => $first) {
$firstName = $this->formatName($first ?? '');
$lastName = $this->formatName($lastNames[$idx] ?? '');
$relation = trim($relations[$idx] ?? '');
$phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? '');
$email = strtolower(trim($emails[$idx] ?? ''));
if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') {
continue;
}
if ($phone === '(000)-000-0000') {
throw new \Exception('Invalid phone number.');
}
if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \Exception('Invalid email format for emergency contact.');
}
$fullName = $firstName . ' ' . $lastName;
$exists = $this->emergencyContactModel->where([
'parent_id' => $parentId,
'emergency_contact_name' => $fullName,
'cellphone' => $phone,
'email' => $email,
'relation' => $relation,
])->first();
if (! $exists) {
$this->emergencyContactModel->insert([
'parent_id' => $parentId,
'emergency_contact_name' => $fullName,
'cellphone' => $phone,
'email' => $email,
'relation' => $relation,
]);
}
}
return ['ok' => true];
}
public function canParentDeleteStudent(array $student, int $parentId): bool
{
$studentId = (int) ($student['id'] ?? 0);
if ($studentId <= 0) {
return false;
}
$statusYear = trim((string) ($student['school_year'] ?? ''));
if ($statusYear === '') {
$statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? '');
}
$isNew = $statusYear !== ''
? service('studentYearStatus')->isNew($studentId, $statusYear)
: ((string) ($student['is_new'] ?? '1') === '1');
return $isNew
&& ! $this->studentHasEnrollmentHistory($studentId, $parentId)
&& ! $this->studentHasClassAssignmentHistory($studentId);
}
public function validateDobAge(
string $dob,
string $registrationAgeDeadline,
int $minAge = 5,
int $maxAge = 18,
?string $schoolYearAgeDeadline = null
): array {
$response = ['isValid' => false, 'message' => '', 'age' => null];
$tz = new DateTimeZone('UTC');
$dob = trim($dob);
if ($dob === '') {
$response['message'] = 'Date of birth is required';
return $response;
}
$birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz);
$errs = DateTimeImmutable::getLastErrors();
if ($birthDate === false || (is_array($errs) && (($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0))) {
$response['message'] = 'Invalid date format (Use YYYY-MM-DD)';
return $response;
}
try {
$minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz);
} catch (Throwable $e) {
$minimumAgeDeadline = new DateTimeImmutable('now', $tz);
}
try {
$ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz);
} catch (Throwable $e) {
$ageDeadline = $minimumAgeDeadline;
}
$minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59);
$ageDeadline = $ageDeadline->setTime(23, 59, 59);
$ageAtDeadline = $birthDate->diff($ageDeadline)->y;
$ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y;
$response['age'] = $ageAtDeadline;
$minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0);
$maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59);
$response['isValid'] = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate);
if (! $response['isValid']) {
$response['message'] = sprintf(
'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d',
$minAge,
$minimumAgeDeadline->format('m-d-Y'),
$maxAge,
$ageDeadline->format('m-d-Y'),
$ageAtMinimumAgeDeadline
);
}
return $response;
}
public function schoolYearAgeDeadline(string $schoolYear, ?string $schoolStartDate = null): string
{
if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) {
return $matches[1] . '-09-01';
}
if (! empty($schoolStartDate) && strtotime($schoolStartDate)) {
return (new DateTimeImmutable($schoolStartDate))->format('Y-m-d');
}
return date('Y') . '-09-01';
}
public function registrationMinimumAgeDeadline(string $schoolYear, ?string $ageDateReference = null): string
{
$configured = trim((string) $ageDateReference);
if ($configured !== '' && strtotime($configured)) {
return (new DateTimeImmutable($configured))->format('Y-m-d');
}
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
return $matches[2] . '-12-31';
}
return date('Y') . '-12-31';
}
public function formatName(string $name): string
{
$name = trim($name);
$name = strtolower($name);
$name = ucwords($name, ' ');
return implode('-', array_map('ucfirst', explode('-', $name)));
}
public function validateNames(string $name): void
{
if (! preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) {
throw new InvalidArgumentException('Invalid name format: Only letters, spaces, or dashes (2-30 chars) allowed.');
}
}
private function getEnrollmentsByParent(int $parentId, string $schoolYear): array
{
return $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('enrollment_date', 'DESC')
->findAll();
}
private function ensureStudentYearStatusRows(array $students, string $schoolYear): void
{
$schoolYear = trim($schoolYear);
if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
return;
}
$studentYearStatus = service('studentYearStatus');
foreach ($students as $student) {
$studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0);
if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) {
continue;
}
$isNew = (int) ($student['is_new'] ?? 1) === 1;
if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) {
log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [
'studentId' => $studentId,
'schoolYear' => $schoolYear,
]);
}
}
}
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
{
$dob = trim((string) $dob);
$schoolYear = trim($schoolYear);
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
return null;
}
try {
$timezone = new DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
$birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
$errors = DateTimeImmutable::getLastErrors();
$hasParseErrors = is_array($errors)
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
if ($birthDate === false || $hasParseErrors) {
return null;
}
$schoolYearStartYearCutoff = new DateTimeImmutable($matches[1] . '-09-01', $timezone);
if ($birthDate > $schoolYearStartYearCutoff) {
return null;
}
return $birthDate->diff($schoolYearStartYearCutoff)->y;
} catch (Throwable $e) {
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
'message' => $e->getMessage(),
]);
return null;
}
}
private function normalizeStudentName(string $name): string
{
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
}
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
{
if (! $this->db->tableExists('enrollment_transition_audits')) {
return;
}
$studentId = (int) ($original['id'] ?? 0);
if ($studentId <= 0) {
return;
}
$changes = [];
foreach (['firstname', 'lastname', 'dob'] as $field) {
$oldValue = trim((string) ($original[$field] ?? ''));
$newValue = trim((string) ($updated[$field] ?? ''));
if ($oldValue !== $newValue) {
$changes[$field] = [
'old_value' => $oldValue,
'new_value' => $newValue,
'changed' => true,
'changed_by' => $parentId,
'changed_at' => date('Y-m-d H:i:s'),
'source' => $source,
];
}
}
if ($changes === []) {
return;
}
$this->db->table('enrollment_transition_audits')->insert([
'student_id' => $studentId,
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
'source_school_year' => null,
'action' => 'parent_student_field_edit',
'performed_by' => $parentId,
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
'reason' => $source,
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function parentEditAffectsEligibility(array $original, array $updated): bool
{
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
}
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($previousSchoolYear === null) {
return;
}
$evaluation = service('enrollmentTransition')->evaluateForParent(
$parentId,
$studentId,
$previousSchoolYear,
$targetSchoolYear,
'parent'
);
if (($evaluation['can_enroll'] ?? false) === true || ($evaluation['parent_enrollment_allowed'] ?? false) === true) {
return;
}
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
session()->setFlashdata('warning', $message);
}
private function previousSchoolYearName(string $schoolYear): ?string
{
if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
return null;
}
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
}
private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool
{
if (! $this->db->tableExists('enrollments')) {
return false;
}
return $this->db->table('enrollments')
->where('student_id', $studentId)
->where('parent_id', $parentId)
->countAllResults() > 0;
}
private function studentHasClassAssignmentHistory(int $studentId): bool
{
if (! $this->db->tableExists('student_class')) {
return false;
}
return $this->db->table('student_class')
->where('student_id', $studentId)
->countAllResults() > 0;
}
}
+44
View File
@@ -54,6 +54,50 @@ class StudentModelTest extends ModelCrudTestCase
$this->assertNotContains($returningStudentId, $ids);
}
public function testEnrollmentWithdrawalRosterExcludesRegisteredOnlyStudents(): void
{
$db = Database::connect('tests');
$schoolYear = $this->validSchoolYear();
$parentId = $this->insertParent($db, 'parent-roster-filter@example.test');
$registeredOnlyId = $this->insertStudent($db, $parentId, 'Registered', 'Only');
$enrolledId = $this->insertStudent($db, $parentId, 'Roster', 'Student');
$db->table('student_year_status')->insert([
'student_id' => $registeredOnlyId,
'school_year' => $schoolYear,
'is_new' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$db->table('student_year_status')->insert([
'student_id' => $enrolledId,
'school_year' => $schoolYear,
'is_new' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$db->table('enrollments')->insert([
'student_id' => $enrolledId,
'class_section_id' => null,
'parent_id' => $parentId,
'enrollment_date' => date('Y-m-d'),
'enrollment_status' => 'admission under review',
'withdrawal_date' => null,
'is_withdrawn' => 0,
'admission_status' => 'pending',
'semester' => 'Fall',
'school_year' => $schoolYear,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$rows = (new StudentModel())->getStudentsWithClassAndEnrollment($schoolYear);
$ids = array_map(static fn (array $row): int => (int) $row['id'], $rows);
$this->assertNotContains($registeredOnlyId, $ids);
$this->assertContains($enrolledId, $ids);
}
private function insertParent($db, string $email): int
{
$db->table('users')->insert([
@@ -0,0 +1,43 @@
<?php
namespace Tests\App\Models;
use App\Models\UserAccessProfileModel;
use CodeIgniter\Test\CIUnitTestCase;
final class UserAccessProfileModelTest extends CIUnitTestCase
{
public function testTeacherAssistantCountsAsTeacher(): void
{
$flags = UserAccessProfileModel::flagsForRoles([
['name' => 'teacher_assistant', 'slug' => 'teacher_assistant'],
]);
$this->assertFalse($flags['is_admin']);
$this->assertTrue($flags['is_teacher']);
$this->assertFalse($flags['is_parent']);
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
}
public function testAnyNonTeacherStaffRoleCountsAsAdmin(): void
{
$flags = UserAccessProfileModel::flagsForRoles([
['name' => 'head of fa', 'slug' => 'head_of_fa'],
]);
$this->assertTrue($flags['is_admin']);
$this->assertFalse($flags['is_teacher']);
$this->assertFalse($flags['is_parent']);
$this->assertSame('admin', UserAccessProfileModel::primaryCategory($flags));
}
public function testMultiRoleUserKeepsAllAccessFlags(): void
{
$flags = UserAccessProfileModel::flagsForRoles(['parent', 'teacher']);
$this->assertFalse($flags['is_admin']);
$this->assertTrue($flags['is_teacher']);
$this->assertTrue($flags['is_parent']);
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
}
}