Files
alrahma_sunday_school/app/Models/UserModel.php
T
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

436 lines
14 KiB
PHP

<?php
namespace App\Models;
use CodeIgniter\Model;
class UserModel extends Model
{
protected $table = 'users'; // Define the table associated with this model
protected $primaryKey = 'id'; // Define the primary key of the table
protected $allowedFields = [
'password',
'account_id',
'lastname',
'firstname',
'gender',
'cellphone',
'email',
'address_street',
'apt',
'city',
'state',
'zip',
'accept_school_policy',
'user_type',
'rfid_tag',
'school_id',
'failed_attempts',
'last_failed_at',
'status',
'is_suspended',
'is_verified',
'token',
'updated_at',
'created_at',
];
protected $returnType = 'array'; // Specify the return type of the results
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
/**
* Get all users with their roles.
*/
public function getUsersWithRoles($sort = 'id', $order = 'asc')
{
return $this->select('users.id, users.firstname, users.lastname, users.email, roles.name as role')
->join('user_roles', 'users.id = user_roles.user_id', 'left')
->join('roles', 'user_roles.role_id = roles.id', 'left')
->orderBy($sort, $order)
->findAll();
}
/**
* Get unverified users created more than 2 minutes ago.
*/
public function getUnverifiedUsers()
{
return $this->where('is_verified', 0)
->where('created_at <', date('Y-m-d H:i:s', time() - 120))
->findAll();
}
/**
* Retrieve all users with optional sorting and filtering.
*/
public function findAll($limit = 0, $offset = 0, $sort = 'id', $order = 'asc', $conditions = [])
{
$builder = $this->builder();
// Apply additional conditions
if (!empty($conditions)) {
$builder->where($conditions);
}
// Apply sorting
$builder->orderBy($sort, $order);
// Apply limit and offset if provided
if ($limit > 0) {
$builder->limit($limit, $offset);
}
return $builder->get()->getResultArray();
}
/**
* Validate registration input data.
*/
public function validateRegistrationInput($data)
{
$errors = [];
// Required fields validation
$required_fields = ['password', 'lastname', 'firstname', 'cellphone', 'email', 'address_street', 'city', 'state', 'zip', 'accept_school_policy'];
foreach ($required_fields as $field) {
if (empty($data[$field])) {
$errors[$field][] = ucfirst($field) . ' is required.';
}
}
// Password validation
if (!empty($data['password'])) {
if (strlen($data['password']) < 8) {
$errors['password'][] = 'Password must be at least 8 characters long.';
}
if (!preg_match('/[A-Z]/', $data['password'])) {
$errors['password'][] = 'Password must contain at least one uppercase letter.';
}
if (!preg_match('/[a-z]/', $data['password'])) {
$errors['password'][] = 'Password must contain at least one lowercase letter.';
}
if (!preg_match('/[0-9]/', $data['password'])) {
$errors['password'][] = 'Password must contain at least one number.';
}
if (!preg_match('/[\W]/', $data['password'])) {
$errors['password'][] = 'Password must contain at least one special character.';
}
}
// Email validation
if (!empty($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'][] = 'Invalid email format.';
}
// Phone validation (basic)
if (!empty($data['cellphone']) && !preg_match('/^\d{10}$/', $data['cellphone'])) {
$errors['cellphone'][] = 'Cellphone must be a 10-digit number.';
}
// Zip code validation
if (!empty($data['zip']) && !preg_match('/^\d{5}(-\d{4})?$/', $data['zip'])) {
$errors['zip'][] = 'Invalid zip code format.';
}
// State validation (example: 2-letter state code)
if (!empty($data['state']) && !preg_match('/^[A-Z]{2}$/', $data['state'])) {
$errors['state'][] = 'Invalid state code.';
}
// Accept school policy validation
if (!isset($data['accept_school_policy']) || $data['accept_school_policy'] != '1') {
$errors['accept_school_policy'][] = 'You must accept the school policy.';
}
return $errors;
}
/**
* Get users by role.
*/
/*
public function getUsersByRole($role)
{
// Join with user_roles and roles tables to fetch users by role
return $this->select('users.*, roles.name as role')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('roles.name', $role)
->findAll();
}*/
/**
* Get user's role name
*
* @param int $user_id User ID
* @return string|null Role name or null if not found
*/
public function getUserRole($user_id)
{
$result = $this->select('roles.name')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('users.id', $user_id)
->first();
return $result ? $result['name'] : null;
}
/**
* Get all users.
*/
public function getAllUsers($sort = 'id', $order = 'asc')
{
return $this->select('users.*') // Select only the fields from the users table
->orderBy($sort, $order)
->findAll();
}
public function getUserInfoById($userId)
{
// Define a list of fields to return (excluding sensitive ones)
$safeFields = array_filter($this->allowedFields, function ($field) {
return !in_array($field, ['password', 'token', 'is_verified']);
});
// Get the main user information
$user = $this->select($safeFields)
->where('id', $userId)
->first();
if (!$user) {
return null;
}
// Load the database connection
$db = \Config\Database::connect();
// Build query to join user_roles and roles
$builder = $db->table('user_roles');
$builder->select('roles.id, roles.name');
$builder->join('roles', 'roles.id = user_roles.role_id');
$builder->where('user_roles.user_id', $userId);
$builder->where('user_roles.deleted_at', null);
$roles = $builder->get();
// Add roles to user array
$user['roles'] = $roles;
return $user;
}
public function getUsersByRole(string $roleName): array
{
return $this->select('users.*')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('roles.name', $roleName)
->where('user_roles.deleted_at', null) // if you're using soft deletes
->findAll();
}
public function getUsersByRoleAndSchoolYear(string $roleName, string $schoolYear): array
{
$builder = $this->select('users.*')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('roles.name', $roleName)
->where('user_roles.deleted_at', null);
if ($this->roleUsesTeacherAssignments($roleName)) {
$builder->join(
'teacher_class tc_year',
'tc_year.teacher_id = users.id AND tc_year.school_year = ' . $this->db->escape($schoolYear),
'inner'
);
}
return $builder->groupBy('users.id')->findAll();
}
public function countAdminsBySchoolYear(string $schoolYear): int
{
// Slugs to exclude from “admin”
$excluded = ['guest', 'teacher', 'teacher_assistant', 'parent'];
// Build a grouped query that flags whether each user has ANY non-excluded role
$builder = $this->db->table('users u')
->select(
// is_admin = 1 if the user has at least one role NOT in the excluded set
'u.id, MAX(CASE WHEN r.slug NOT IN("guest","teacher","teacher_assistant","parent") THEN 1 ELSE 0 END) AS is_admin',
false // don't escape the CASE expression
)
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('r.is_active', 1)
->where('LOWER(r.name) NOT IN ("guest","teacher","teacher_assistant","parent")', null, false)
->groupBy('u.id')
->having('is_admin', 1);
// Wrap as subquery to count rows without fetching them all
$subSql = $builder->getCompiledSelect();
$row = $this->db->query("SELECT COUNT(*) AS total FROM ({$subSql}) t")->getRowArray();
return (int)($row['total'] ?? 0);
}
public function getSchoolIdByUserId(int $userId): ?int
{
$result = $this->select('school_id')
->where('id', $userId)
->first();
return $result['school_id'] ?? null;
}
public function getParents(): array
{
$db = \Config\Database::connect();
// Get parent role ID
$parentRole = $db->table('roles')
->select('id')
->where('LOWER(name)', 'parent') // case-insensitive match
->get()
->getRow();
if (!$parentRole) {
return []; // No parent role found
}
// Get users with parent role
return $this->select('users.id, users.firstname, users.lastname, users.email, users.school_id')
->join('user_roles', 'user_roles.user_id = users.id')
->where('user_roles.role_id', $parentRole->id)
->groupBy('users.id, users.firstname, users.lastname, users.email, users.school_id') // group by all selected fields (for strict SQL modes)
->findAll();
}
public function getTeacher($user_id)
{
return $this->db->table($this->table)
->select('users.firstname AS teacher_first, users.lastname AS teacher_last')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('users.id', $user_id)
->where('LOWER(roles.name) =', 'teacher') // case-insensitive match
->get()
->getRowArray();
}
public function getUsersBySchoolId($schoolId)
{
return $this->where('school_id', $schoolId)
->orderBy('lastname', 'ASC')
->findAll();
}
public function getNoParentUsersWithRole(
?string $schoolYear = null,
array $selectedUserIds = [],
string $sort = 'users.lastname',
string $order = 'asc'
): array {
$order = (strtolower($order) === 'desc') ? 'DESC' : 'ASC';
// Whitelist sort fields
$allowedSort = ['users.id', 'users.firstname', 'users.lastname', 'users.email', 'roles'];
if (!in_array($sort, $allowedSort, true)) {
$sort = 'users.lastname';
}
$builder = $this->select("
users.id AS id,
users.firstname,
users.lastname,
users.email,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles
")
->join('user_roles ur', 'users.id = ur.user_id', 'inner')
->join('roles r', 'ur.role_id = r.id', 'inner')
// drop parent and guest rows; users with only those vanish
->whereNotIn('r.name', ['parent', 'guest']);
if (!empty($selectedUserIds)) {
$builder->whereIn('users.id', $selectedUserIds);
}
if (!empty($schoolYear)) {
$escYear = $this->db->escape($schoolYear);
// Include user if:
// (A) They are assigned as teacher/TA in teacher_class for that year
// OR
// (B) They have at least one non-teacher-ish (and non-parent) global role.
$builder->groupStart()
->where("
EXISTS (
SELECT 1
FROM teacher_class tc
WHERE tc.teacher_id = users.id
AND tc.school_year = {$escYear}
)
", null, false)
->orWhere("
EXISTS (
SELECT 1
FROM user_roles ur2
JOIN roles r2 ON r2.id = ur2.role_id
WHERE ur2.user_id = users.id
AND LOWER(r2.name) NOT IN ('parent','ta')
AND LOWER(r2.name) NOT LIKE '%teacher%'
)
", null, false)
->groupEnd();
}
return $builder
->groupBy('users.id')
->orderBy($sort === 'roles' ? 'roles' : $sort, $order)
->findAll();
}
private function roleUsesTeacherAssignments(string $roleName): bool
{
$role = strtolower(trim($roleName));
return $role === 'ta' || str_contains($role, 'teacher');
}
}