diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 4b88eaf..1a1dc83 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -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) { diff --git a/app/Database/Migrations/2026-08-29-000200_CreateUserAccessProfiles.php b/app/Database/Migrations/2026-08-29-000200_CreateUserAccessProfiles.php new file mode 100644 index 0000000..94579c3 --- /dev/null +++ b/app/Database/Migrations/2026-08-29-000200_CreateUserAccessProfiles.php @@ -0,0 +1,132 @@ +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]); + } +} diff --git a/app/Models/UserAccessProfileModel.php b/app/Models/UserAccessProfileModel.php new file mode 100644 index 0000000..d952450 --- /dev/null +++ b/app/Models/UserAccessProfileModel.php @@ -0,0 +1,186 @@ +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); + } +} diff --git a/app/Models/UserRoleModel.php b/app/Models/UserRoleModel.php index a0c562a..06a50dd 100644 --- a/app/Models/UserRoleModel.php +++ b/app/Models/UserRoleModel.php @@ -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; diff --git a/tests/app/Models/UserAccessProfileModelTest.php b/tests/app/Models/UserAccessProfileModelTest.php new file mode 100644 index 0000000..f1fb7d3 --- /dev/null +++ b/tests/app/Models/UserAccessProfileModelTest.php @@ -0,0 +1,43 @@ + '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)); + } +}