Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 140be9922d | |||
| 361d0c0d3a | |||
| 0f8ad86b4f |
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user