added school tenant
This commit is contained in:
parent
f0d7d2a443
commit
768a13ce29
@ -53,6 +53,8 @@ export interface CurrentUser {
|
||||
app_role?: {
|
||||
globalAccess?: boolean | null;
|
||||
name?: string | null;
|
||||
/** Authorization scope of the role (`roles.scope`): organization/school/campus/… */
|
||||
scope?: string | null;
|
||||
/** Present on the loaded role instance attached to the request. */
|
||||
getPermissions?: () => Promise<PermissionLike[]>;
|
||||
} | null;
|
||||
@ -69,8 +71,11 @@ export interface CurrentUser {
|
||||
email?: string | null;
|
||||
campusId?: string | null;
|
||||
campus?: { code?: string | null; name?: string | null } | null;
|
||||
schoolId?: string | null;
|
||||
school?: { id?: string | null; name?: string | null } | null;
|
||||
staff_user?: Array<{
|
||||
campusId?: string | null;
|
||||
schoolId?: string | null;
|
||||
staff_type?: string | null;
|
||||
campus?: { code?: string | null; name?: string | null } | null;
|
||||
}> | null;
|
||||
|
||||
100
backend/src/db/migrations/20260612010000-add-schools-tier.ts
Normal file
100
backend/src/db/migrations/20260612010000-add-schools-tier.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { DataTypes, type QueryInterface } from 'sequelize';
|
||||
|
||||
/**
|
||||
* School tier (American Organization → School → Campus hierarchy). Adds the
|
||||
* `schools` table and a nullable `schoolId` foreign key on `campuses`, `users`,
|
||||
* and `staff`. Like every other relation in this codebase the link is an
|
||||
* app-level UUID column with no DB-level constraint (`constraints: false` in the
|
||||
* models). `schoolId` is left nullable here; the reseed assigns every campus to
|
||||
* a school (campus belongs to exactly one school). Idempotent: the table and
|
||||
* each column are only created if missing.
|
||||
*/
|
||||
async function tableExists(
|
||||
queryInterface: QueryInterface,
|
||||
table: string,
|
||||
): Promise<boolean> {
|
||||
const [results] = await queryInterface.sequelize.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = '${table}'
|
||||
`);
|
||||
return (results as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
async function columnExists(
|
||||
queryInterface: QueryInterface,
|
||||
table: string,
|
||||
column: string,
|
||||
): Promise<boolean> {
|
||||
const [results] = await queryInterface.sequelize.query(`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = '${table}' AND column_name = '${column}'
|
||||
`);
|
||||
return (results as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
async function addSchoolIdColumn(
|
||||
queryInterface: QueryInterface,
|
||||
table: string,
|
||||
): Promise<void> {
|
||||
if (!(await columnExists(queryInterface, table, 'schoolId'))) {
|
||||
await queryInterface.addColumn(table, 'schoolId', {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
if (!(await tableExists(queryInterface, 'schools'))) {
|
||||
await queryInterface.createTable('schools', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: { type: DataTypes.TEXT, allowNull: false },
|
||||
code: { type: DataTypes.TEXT, allowNull: false },
|
||||
address: { type: DataTypes.TEXT, allowNull: true },
|
||||
phone: { type: DataTypes.TEXT, allowNull: true },
|
||||
email: { type: DataTypes.TEXT, allowNull: true },
|
||||
description: { type: DataTypes.TEXT, allowNull: true },
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
},
|
||||
organizationId: { type: DataTypes.UUID, allowNull: true },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
createdAt: { type: DataTypes.DATE, allowNull: false },
|
||||
updatedAt: { type: DataTypes.DATE, allowNull: false },
|
||||
deletedAt: { type: DataTypes.DATE, allowNull: true },
|
||||
});
|
||||
}
|
||||
|
||||
await addSchoolIdColumn(queryInterface, 'campuses');
|
||||
await addSchoolIdColumn(queryInterface, 'users');
|
||||
await addSchoolIdColumn(queryInterface, 'staff');
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
if (await columnExists(queryInterface, 'staff', 'schoolId')) {
|
||||
await queryInterface.removeColumn('staff', 'schoolId');
|
||||
}
|
||||
if (await columnExists(queryInterface, 'users', 'schoolId')) {
|
||||
await queryInterface.removeColumn('users', 'schoolId');
|
||||
}
|
||||
if (await columnExists(queryInterface, 'campuses', 'schoolId')) {
|
||||
await queryInterface.removeColumn('campuses', 'schoolId');
|
||||
}
|
||||
if (await tableExists(queryInterface, 'schools')) {
|
||||
await queryInterface.dropTable('schools');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -17,6 +17,7 @@ import type { AttendanceSessions } from './attendance_sessions';
|
||||
import type { Classes } from './classes';
|
||||
import type { Messages } from './messages';
|
||||
import type { Organizations } from './organizations';
|
||||
import type { Schools } from './schools';
|
||||
import type { Staff } from './staff';
|
||||
import type { Timetables } from './timetables';
|
||||
import type { Users } from './users';
|
||||
@ -44,6 +45,8 @@ export class Campuses extends Model<
|
||||
declare active: CreationOptional<boolean>;
|
||||
declare importHash: CreationOptional<string | null>;
|
||||
declare organizationId: CreationOptional<string | null>;
|
||||
/** School scope (Organization → School → Campus). Nullable until backfilled. */
|
||||
declare schoolId: CreationOptional<string | null>;
|
||||
declare createdById: CreationOptional<string | null>;
|
||||
declare updatedById: CreationOptional<string | null>;
|
||||
declare createdAt: CreationOptional<Date>;
|
||||
@ -63,6 +66,8 @@ export class Campuses extends Model<
|
||||
declare setMessages_campus: HasManySetAssociationsMixin<Messages, string>;
|
||||
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
|
||||
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
|
||||
declare getSchool: BelongsToGetAssociationMixin<Schools>;
|
||||
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
|
||||
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
|
||||
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
@ -105,6 +110,12 @@ export class Campuses extends Model<
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.campuses.belongsTo(db.schools, {
|
||||
as: 'school',
|
||||
foreignKey: { name: 'schoolId' },
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.campuses.belongsTo(db.users, { as: 'createdBy' });
|
||||
db.campuses.belongsTo(db.users, { as: 'updatedBy' });
|
||||
}
|
||||
@ -157,6 +168,7 @@ export default function (sequelize: Sequelize): typeof Campuses {
|
||||
unique: true,
|
||||
},
|
||||
organizationId: { type: DataTypes.UUID, allowNull: true },
|
||||
schoolId: { type: DataTypes.UUID, allowNull: true },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
createdAt: { type: DataTypes.DATE },
|
||||
|
||||
@ -34,6 +34,7 @@ import policy_acknowledgments from './policy_acknowledgments';
|
||||
import policy_documents from './policy_documents';
|
||||
import roles from './roles';
|
||||
import safety_quiz_results from './safety_quiz_results';
|
||||
import schools from './schools';
|
||||
import staff from './staff';
|
||||
import staff_attendance_records from './staff_attendance_records';
|
||||
import subjects from './subjects';
|
||||
@ -129,6 +130,7 @@ const models = {
|
||||
policy_documents: policy_documents(sequelize),
|
||||
roles: roles(sequelize),
|
||||
safety_quiz_results: safety_quiz_results(sequelize),
|
||||
schools: schools(sequelize),
|
||||
staff: staff(sequelize),
|
||||
staff_attendance_records: staff_attendance_records(sequelize),
|
||||
subjects: subjects(sequelize),
|
||||
|
||||
@ -110,6 +110,14 @@ export class Organizations extends Model<
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.organizations.hasMany(db.schools, {
|
||||
as: 'schools_organization',
|
||||
foreignKey: {
|
||||
name: 'organizationId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
db.organizations.hasMany(db.academic_years, {
|
||||
as: 'academic_years_organization',
|
||||
|
||||
114
backend/src/db/models/schools.ts
Normal file
114
backend/src/db/models/schools.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import {
|
||||
DataTypes,
|
||||
Model,
|
||||
type CreationOptional,
|
||||
type InferAttributes,
|
||||
type InferCreationAttributes,
|
||||
type Sequelize,
|
||||
} from 'sequelize';
|
||||
import type { Db } from '@/db/types';
|
||||
import type {
|
||||
BelongsToGetAssociationMixin,
|
||||
BelongsToSetAssociationMixin,
|
||||
HasManyGetAssociationsMixin,
|
||||
HasManySetAssociationsMixin,
|
||||
} from 'sequelize';
|
||||
import type { Campuses } from './campuses';
|
||||
import type { Organizations } from './organizations';
|
||||
import type { Users } from './users';
|
||||
|
||||
/**
|
||||
* School tier (American Organization → School → Campus hierarchy). A school
|
||||
* belongs to one organization and owns one or more campuses. The school head is
|
||||
* the Principal (school scope); campuses keep their own per-campus `timezone`
|
||||
* (a school may span timezones), so timezone is intentionally NOT on this model.
|
||||
*/
|
||||
export class Schools extends Model<
|
||||
InferAttributes<Schools>,
|
||||
InferCreationAttributes<Schools>
|
||||
> {
|
||||
declare id: CreationOptional<string>;
|
||||
declare name: string;
|
||||
declare code: string;
|
||||
declare address: string | null;
|
||||
declare phone: string | null;
|
||||
declare email: string | null;
|
||||
declare description: string | null;
|
||||
declare active: CreationOptional<boolean>;
|
||||
declare importHash: CreationOptional<string | null>;
|
||||
declare organizationId: CreationOptional<string | null>;
|
||||
declare createdById: CreationOptional<string | null>;
|
||||
declare updatedById: CreationOptional<string | null>;
|
||||
declare createdAt: CreationOptional<Date>;
|
||||
declare updatedAt: CreationOptional<Date>;
|
||||
declare deletedAt: CreationOptional<Date | null>;
|
||||
|
||||
declare getCampuses_school: HasManyGetAssociationsMixin<Campuses>;
|
||||
declare setCampuses_school: HasManySetAssociationsMixin<Campuses, string>;
|
||||
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
|
||||
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
|
||||
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
|
||||
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
declare setUpdatedBy: BelongsToSetAssociationMixin<Users, string>;
|
||||
|
||||
static associate(db: Db): void {
|
||||
db.schools.hasMany(db.campuses, {
|
||||
as: 'campuses_school',
|
||||
foreignKey: { name: 'schoolId' },
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.schools.belongsTo(db.organizations, {
|
||||
as: 'organization',
|
||||
foreignKey: { name: 'organizationId' },
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.schools.belongsTo(db.users, { as: 'createdBy' });
|
||||
db.schools.belongsTo(db.users, { as: 'updatedBy' });
|
||||
}
|
||||
}
|
||||
|
||||
export default function (sequelize: Sequelize): typeof Schools {
|
||||
Schools.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: { type: DataTypes.TEXT, allowNull: false },
|
||||
code: { type: DataTypes.TEXT, allowNull: false },
|
||||
address: { type: DataTypes.TEXT },
|
||||
phone: { type: DataTypes.TEXT },
|
||||
email: { type: DataTypes.TEXT },
|
||||
description: { type: DataTypes.TEXT },
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
},
|
||||
organizationId: { type: DataTypes.UUID, allowNull: true },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
createdAt: { type: DataTypes.DATE },
|
||||
updatedAt: { type: DataTypes.DATE },
|
||||
deletedAt: { type: DataTypes.DATE },
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'schools',
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
freezeTableName: true,
|
||||
},
|
||||
);
|
||||
|
||||
return Schools;
|
||||
}
|
||||
@ -20,6 +20,7 @@ import type { ClassSubjects } from './class_subjects';
|
||||
import type { Classes } from './classes';
|
||||
import type { File } from './file';
|
||||
import type { Organizations } from './organizations';
|
||||
import type { Schools } from './schools';
|
||||
import type { Users } from './users';
|
||||
|
||||
export class Staff extends Model<
|
||||
@ -38,6 +39,8 @@ export class Staff extends Model<
|
||||
declare deletedAt: CreationOptional<Date | null>;
|
||||
declare campusId: CreationOptional<string | null>;
|
||||
declare organizationId: CreationOptional<string | null>;
|
||||
/** School scope (Organization → School → Campus). Nullable. */
|
||||
declare schoolId: CreationOptional<string | null>;
|
||||
declare userId: CreationOptional<string | null>;
|
||||
declare createdById: CreationOptional<string | null>;
|
||||
declare updatedById: CreationOptional<string | null>;
|
||||
@ -53,6 +56,8 @@ export class Staff extends Model<
|
||||
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
|
||||
declare getCampus: BelongsToGetAssociationMixin<Campuses>;
|
||||
declare setCampus: BelongsToSetAssociationMixin<Campuses, string>;
|
||||
declare getSchool: BelongsToGetAssociationMixin<Schools>;
|
||||
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
|
||||
// Eager-loaded association (populated by `include`).
|
||||
declare campus?: NonAttribute<Campuses>;
|
||||
declare getUser: BelongsToGetAssociationMixin<Users>;
|
||||
@ -141,6 +146,14 @@ export class Staff extends Model<
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.staff.belongsTo(db.schools, {
|
||||
as: 'school',
|
||||
foreignKey: {
|
||||
name: 'schoolId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.staff.belongsTo(db.users, {
|
||||
as: 'user',
|
||||
foreignKey: {
|
||||
@ -250,6 +263,7 @@ status: {
|
||||
deletedAt: { type: DataTypes.DATE },
|
||||
campusId: { type: DataTypes.UUID, allowNull: true },
|
||||
organizationId: { type: DataTypes.UUID, allowNull: true },
|
||||
schoolId: { type: DataTypes.UUID, allowNull: true },
|
||||
userId: { type: DataTypes.UUID, allowNull: true },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
|
||||
@ -29,6 +29,7 @@ import type { Messages } from './messages';
|
||||
import type { Organizations } from './organizations';
|
||||
import type { Permissions } from './permissions';
|
||||
import type { Roles } from './roles';
|
||||
import type { Schools } from './schools';
|
||||
import type { Staff } from './staff';
|
||||
|
||||
const providers = config.providers;
|
||||
@ -55,6 +56,8 @@ export class Users extends Model<
|
||||
declare organizationId: CreationOptional<string | null>;
|
||||
/** Campus scope for campus-bound roles (Workstream 3 §3.1). Nullable. */
|
||||
declare campusId: CreationOptional<string | null>;
|
||||
/** School scope for school-bound roles (Principal/Registrar). Nullable. */
|
||||
declare schoolId: CreationOptional<string | null>;
|
||||
declare createdById: CreationOptional<string | null>;
|
||||
declare updatedById: CreationOptional<string | null>;
|
||||
declare createdAt: CreationOptional<Date>;
|
||||
@ -82,6 +85,8 @@ export class Users extends Model<
|
||||
declare setOrganizations: BelongsToSetAssociationMixin<Organizations, string>;
|
||||
declare getCampus: BelongsToGetAssociationMixin<Campuses>;
|
||||
declare setCampus: BelongsToSetAssociationMixin<Campuses, string>;
|
||||
declare getSchool: BelongsToGetAssociationMixin<Schools>;
|
||||
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
|
||||
declare getAvatar: HasManyGetAssociationsMixin<File>;
|
||||
declare setAvatar: HasManySetAssociationsMixin<File, string>;
|
||||
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
|
||||
@ -148,6 +153,14 @@ export class Users extends Model<
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.users.belongsTo(db.schools, {
|
||||
as: 'school',
|
||||
foreignKey: {
|
||||
name: 'schoolId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
db.users.hasMany(db.file, {
|
||||
as: 'avatar',
|
||||
foreignKey: 'belongsToId',
|
||||
@ -238,6 +251,7 @@ export default function (sequelize: Sequelize): typeof Users {
|
||||
},
|
||||
organizationId: { type: DataTypes.UUID, allowNull: true },
|
||||
campusId: { type: DataTypes.UUID, allowNull: true },
|
||||
schoolId: { type: DataTypes.UUID, allowNull: true },
|
||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
||||
createdAt: { type: DataTypes.DATE },
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
MODULE_READ_INSTRUCTIONAL,
|
||||
MODULE_READ_PARENT_COMM,
|
||||
MODULE_READ_EXTERNAL,
|
||||
MODULE_READ_DIRECTOR,
|
||||
MODULE_ACTIONS,
|
||||
MODULE_PERMISSIONS,
|
||||
} from '@/shared/constants/product-permissions';
|
||||
@ -45,15 +46,19 @@ const PERMISSION_ENTITIES = [
|
||||
const CRUD_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE'];
|
||||
const EXTRA_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH'];
|
||||
|
||||
/** Roles granted every permission (full CRUD within their tenant/campus scope). */
|
||||
/** Roles granted every permission (full CRUD within their tenant/scope). */
|
||||
const FULL_ACCESS_ROLES: readonly RoleName[] = [
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
// School head: full access, constrained to the school by tenant scoping.
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
];
|
||||
|
||||
/** Roles granted read-only access to tenant resources. */
|
||||
const READ_ONLY_ROLES: readonly RoleName[] = [
|
||||
// Registrar: the Principal's read-only/audit assistant (school-wide visibility).
|
||||
ROLE_NAMES.REGISTRAR,
|
||||
ROLE_NAMES.OFFICE_MANAGER,
|
||||
ROLE_NAMES.TEACHER,
|
||||
ROLE_NAMES.SUPPORT_STAFF,
|
||||
@ -77,6 +82,16 @@ const EXTERNAL_ROLES: readonly RoleName[] = [
|
||||
* `student`/`guardian` get only the external pages.
|
||||
*/
|
||||
const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> = {
|
||||
// Registrar: read every product surface across the school for audit, but no
|
||||
// action permissions (no fill-attendance/quiz/ack/zone, no audio manage).
|
||||
[ROLE_NAMES.REGISTRAR]: [
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_INSTRUCTIONAL,
|
||||
...MODULE_READ_PARENT_COMM,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
...MODULE_READ_DIRECTOR,
|
||||
'READ_AUDIO_FILES',
|
||||
],
|
||||
[ROLE_NAMES.OFFICE_MANAGER]: [
|
||||
...MODULE_READ_ALL_STAFF,
|
||||
...MODULE_READ_EXTERNAL,
|
||||
|
||||
@ -12,17 +12,24 @@ import {
|
||||
SEED_ORGANIZATION_2_NAME,
|
||||
SEED_SECONDARY_OWNER,
|
||||
SEED_CAMPUS_ID,
|
||||
SEED_SCHOOL_ID,
|
||||
SEED_SCHOOL_NAME,
|
||||
SEED_SCHOOL_2_ID,
|
||||
SEED_SCHOOL_2_NAME,
|
||||
SEED_SCHOOL_CAMPUS_IDS,
|
||||
SEED_FIXTURE_USERS,
|
||||
} from '@/shared/constants/seed-fixtures';
|
||||
import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses';
|
||||
import type { Organizations } from '@/db/models/organizations';
|
||||
import type { Schools } from '@/db/models/schools';
|
||||
import type { Staff } from '@/db/models/staff';
|
||||
|
||||
/**
|
||||
* RBAC fixture links (Workstream 4): one company that owns the seeded campuses,
|
||||
* the per-role users tied to the company/campus, and staff profiles covering
|
||||
* every campus staff role on the `tigers` campus. Runs after the user and role
|
||||
* seeders. Idempotent and reversible.
|
||||
* RBAC fixture links (Workstream 4): one company that owns two schools and the
|
||||
* seeded campuses (each campus assigned to one school), the per-role users tied
|
||||
* to the org/school/campus, and staff profiles covering every campus staff role
|
||||
* on the `tigers` campus. Runs after the user and role seeders. Idempotent and
|
||||
* reversible.
|
||||
*/
|
||||
const campusIds = PRODUCT_CAMPUS_SEED_ROWS.map((campus) => campus.id);
|
||||
const staffFixtures = SEED_FIXTURE_USERS.filter((user) => user.staffType);
|
||||
@ -63,19 +70,46 @@ export default {
|
||||
},
|
||||
);
|
||||
|
||||
// 2. The company owns the seeded campuses.
|
||||
// 2. The two schools under the primary company (idempotent).
|
||||
const schoolSeeds: CreationAttributes<Schools>[] = [
|
||||
{ id: SEED_SCHOOL_ID, name: SEED_SCHOOL_NAME, code: 'north', active: true, organizationId: SEED_ORGANIZATION_ID, createdAt, updatedAt },
|
||||
{ id: SEED_SCHOOL_2_ID, name: SEED_SCHOOL_2_NAME, code: 'south', active: true, organizationId: SEED_ORGANIZATION_ID, createdAt, updatedAt },
|
||||
];
|
||||
const existingSchools = await queryInterface.sequelize.query<{ id: string }>(
|
||||
`SELECT id FROM schools WHERE id IN (:ids)`,
|
||||
{
|
||||
replacements: { ids: schoolSeeds.map((s) => s.id) },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
const existingSchoolIds = new Set(existingSchools.map((row) => row.id));
|
||||
const schoolsToInsert = schoolSeeds.filter(
|
||||
(s) => !existingSchoolIds.has(s.id as string),
|
||||
);
|
||||
if (schoolsToInsert.length > 0) {
|
||||
await queryInterface.bulkInsert('schools', schoolsToInsert);
|
||||
}
|
||||
|
||||
// 3. The company owns the seeded campuses; each campus belongs to one school.
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "campuses" SET "organizationId" = :org WHERE "id" IN (:ids)`,
|
||||
{ replacements: { org: SEED_ORGANIZATION_ID, ids: campusIds } },
|
||||
);
|
||||
for (const [schoolId, ids] of Object.entries(SEED_SCHOOL_CAMPUS_IDS)) {
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "campuses" SET "schoolId" = :school WHERE "id" IN (:ids)`,
|
||||
{ replacements: { school: schoolId, ids: [...ids] } },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Tie each fixture user to the company/campus per its scope.
|
||||
// 4. Tie each fixture user to the org/school/campus per its scope.
|
||||
for (const user of SEED_FIXTURE_USERS) {
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "users" SET "organizationId" = :org, "campusId" = :campus WHERE "id" = :id`,
|
||||
`UPDATE "users" SET "organizationId" = :org, "schoolId" = :school, "campusId" = :campus WHERE "id" = :id`,
|
||||
{
|
||||
replacements: {
|
||||
org: user.organization ? SEED_ORGANIZATION_ID : null,
|
||||
school: user.school ? SEED_SCHOOL_ID : null,
|
||||
campus: user.campus ? SEED_CAMPUS_ID : null,
|
||||
id: user.id,
|
||||
},
|
||||
@ -83,7 +117,7 @@ export default {
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Staff profiles for the campus staff roles (idempotent by userId).
|
||||
// 5. Staff profiles for the campus staff roles (idempotent by userId).
|
||||
const existingStaff = await queryInterface.sequelize.query<{
|
||||
userId: string;
|
||||
}>(`SELECT "userId" FROM staff WHERE "userId" IN (:ids)`, {
|
||||
@ -100,6 +134,7 @@ export default {
|
||||
staff_type: user.staffType ?? null,
|
||||
status: 'active',
|
||||
organizationId: SEED_ORGANIZATION_ID,
|
||||
schoolId: user.school ? SEED_SCHOOL_ID : null,
|
||||
campusId: SEED_CAMPUS_ID,
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
@ -118,7 +153,7 @@ export default {
|
||||
{},
|
||||
);
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "users" SET "organizationId" = NULL, "campusId" = NULL WHERE "id" IN (:ids)`,
|
||||
`UPDATE "users" SET "organizationId" = NULL, "schoolId" = NULL, "campusId" = NULL WHERE "id" IN (:ids)`,
|
||||
{
|
||||
replacements: {
|
||||
ids: [
|
||||
@ -129,9 +164,14 @@ export default {
|
||||
},
|
||||
);
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "campuses" SET "organizationId" = NULL WHERE "id" IN (:ids)`,
|
||||
`UPDATE "campuses" SET "organizationId" = NULL, "schoolId" = NULL WHERE "id" IN (:ids)`,
|
||||
{ replacements: { ids: campusIds } },
|
||||
);
|
||||
await queryInterface.bulkDelete(
|
||||
'schools',
|
||||
{ id: { [Op.in]: [SEED_SCHOOL_ID, SEED_SCHOOL_2_ID] } },
|
||||
{},
|
||||
);
|
||||
await queryInterface.bulkDelete(
|
||||
'organizations',
|
||||
{ id: { [Op.in]: [SEED_ORGANIZATION_ID, SEED_ORGANIZATION_2_ID] } },
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { clampLimit, requiredIsoDate } from '@/services/shared/validate';
|
||||
import { Op } from 'sequelize';
|
||||
import { Op, literal } from 'sequelize';
|
||||
import db from '@/db/models';
|
||||
import { withTransaction } from '@/db/with-transaction';
|
||||
import ForbiddenError from '@/shared/errors/forbidden';
|
||||
@ -8,9 +8,9 @@ import {
|
||||
CAMPUS_ATTENDANCE_DEFAULT_LIMIT,
|
||||
CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES,
|
||||
CAMPUS_ATTENDANCE_MAX_LIMIT,
|
||||
CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
|
||||
normalizeCampusKey,
|
||||
} from '@/shared/constants/campus-attendance';
|
||||
import { ROLE_SCOPES } from '@/shared/constants/roles';
|
||||
import { resolvePagination } from '@/shared/constants/pagination';
|
||||
import type { CampusAttendanceConfig } from '@/db/models/campus_attendance_config';
|
||||
import type { CampusAttendanceSummaries } from '@/db/models/campus_attendance_summaries';
|
||||
@ -23,12 +23,19 @@ import type {
|
||||
import {
|
||||
assertAuthenticatedTenantUser,
|
||||
getCampusId,
|
||||
getRoleScope,
|
||||
getSchoolId,
|
||||
hasGlobalAccess,
|
||||
hasRoleAccess,
|
||||
requireOrganizationId,
|
||||
requireUserId,
|
||||
getDisplayName,
|
||||
} from '@/services/shared/access';
|
||||
|
||||
/** UUIDs are inlined into a SQL literal subquery; reject anything that is not one. */
|
||||
const CA_UUID_RE =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
|
||||
function getCurrentUserCampusKey(currentUser?: CurrentUser): string | null {
|
||||
const staff = currentUser?.staff_user;
|
||||
const staffProfile = Array.isArray(staff) ? staff[0] : null;
|
||||
@ -66,16 +73,74 @@ function campusKeyFromRoute(value: unknown): string {
|
||||
return campusKey;
|
||||
}
|
||||
|
||||
function assertCanAccessCampusKey(
|
||||
/** True for roles whose reach is the whole organization (global + org scope). */
|
||||
function isOrgWide(currentUser?: CurrentUser): boolean {
|
||||
return (
|
||||
hasGlobalAccess(currentUser) ||
|
||||
getRoleScope(currentUser) === ROLE_SCOPES.ORGANIZATION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subquery of the campus_keys a school owns. `campus_key` is the normalized
|
||||
* campus code, and the seeded product codes are already lowercase slugs, so
|
||||
* `LOWER("code")` matches the stored key. Inlines a validated UUID only.
|
||||
*/
|
||||
function schoolCampusKeySubquery(schoolId: string) {
|
||||
if (!CA_UUID_RE.test(schoolId)) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
return literal(
|
||||
`(SELECT LOWER("code") FROM "campuses" WHERE "schoolId" = '${schoolId}' AND "deletedAt" IS NULL)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether `campusKey` belongs to a campus in the user's school (parameterized). */
|
||||
async function isCampusKeyInSchool(
|
||||
campusKey: string,
|
||||
schoolId: string,
|
||||
organizationId: string,
|
||||
): Promise<boolean> {
|
||||
const count = await db.campuses.count({
|
||||
where: {
|
||||
schoolId,
|
||||
organizationId,
|
||||
[Op.and]: [
|
||||
db.sequelize.where(
|
||||
db.sequelize.fn('LOWER', db.sequelize.col('code')),
|
||||
campusKey,
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/** Asserts the user may read/write the given campus_key, by scope tier. */
|
||||
async function assertCanAccessCampusKey(
|
||||
campusKey: string,
|
||||
currentUser?: CurrentUser,
|
||||
): void {
|
||||
if (hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES)) {
|
||||
): Promise<void> {
|
||||
if (isOrgWide(currentUser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCampusKey = getCurrentUserCampusKey(currentUser);
|
||||
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
|
||||
const schoolId = getSchoolId(currentUser);
|
||||
if (
|
||||
schoolId &&
|
||||
(await isCampusKeyInSchool(
|
||||
campusKey,
|
||||
schoolId,
|
||||
requireOrganizationId(currentUser),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
const currentCampusKey = getCurrentUserCampusKey(currentUser);
|
||||
if (currentCampusKey && currentCampusKey === campusKey) {
|
||||
return;
|
||||
}
|
||||
@ -83,22 +148,31 @@ function assertCanAccessCampusKey(
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
/** Resolves the campus_key scope, asserting access along the way. */
|
||||
function campusScope(
|
||||
/** Resolves the campus_key scope by tier, asserting access along the way. */
|
||||
async function campusScope(
|
||||
filter: CampusAttendanceFilter,
|
||||
currentUser?: CurrentUser,
|
||||
): { campus_key?: string } {
|
||||
): Promise<{ campus_key?: string | { [Op.in]: ReturnType<typeof literal> } }> {
|
||||
const requestedCampusKey = normalizeCampusKey(filter?.campusKey);
|
||||
|
||||
if (requestedCampusKey) {
|
||||
assertCanAccessCampusKey(requestedCampusKey, currentUser);
|
||||
await assertCanAccessCampusKey(requestedCampusKey, currentUser);
|
||||
return { campus_key: requestedCampusKey };
|
||||
}
|
||||
|
||||
if (hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES)) {
|
||||
if (isOrgWide(currentUser)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// School roles (Principal/Registrar): every campus in their school.
|
||||
if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) {
|
||||
const schoolId = getSchoolId(currentUser);
|
||||
if (!schoolId) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
return { campus_key: { [Op.in]: schoolCampusKeySubquery(schoolId) } };
|
||||
}
|
||||
|
||||
const currentCampusKey = getCurrentUserCampusKey(currentUser);
|
||||
|
||||
if (!currentCampusKey) {
|
||||
@ -222,7 +296,7 @@ class CampusAttendanceService {
|
||||
const result = await db.campus_attendance_config.findAndCountAll({
|
||||
where: {
|
||||
organizationId: requireOrganizationId(currentUser),
|
||||
...campusScope(filter, currentUser),
|
||||
...(await campusScope(filter, currentUser)),
|
||||
},
|
||||
order: [['campus_key', 'asc']],
|
||||
limit,
|
||||
@ -243,7 +317,7 @@ class CampusAttendanceService {
|
||||
assertCanManageCampusAttendance(currentUser);
|
||||
|
||||
const campusKey = campusKeyFromRoute(campusKeyParam);
|
||||
assertCanAccessCampusKey(campusKey, currentUser);
|
||||
await assertCanAccessCampusKey(campusKey, currentUser);
|
||||
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new ValidationError();
|
||||
@ -285,7 +359,7 @@ class CampusAttendanceService {
|
||||
const result = await db.campus_attendance_summaries.findAndCountAll({
|
||||
where: {
|
||||
organizationId: requireOrganizationId(currentUser),
|
||||
...campusScope(filter, currentUser),
|
||||
...(await campusScope(filter, currentUser)),
|
||||
...dateRange(filter),
|
||||
},
|
||||
limit: clampLimit(filter.limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT),
|
||||
@ -311,7 +385,7 @@ class CampusAttendanceService {
|
||||
|
||||
const campusKey = campusKeyFromRoute(campusKeyParam);
|
||||
const attendanceDate = requiredIsoDate(dateParam);
|
||||
assertCanAccessCampusKey(campusKey, currentUser);
|
||||
await assertCanAccessCampusKey(campusKey, currentUser);
|
||||
|
||||
const validatedSummary = validateSummary(data);
|
||||
const payload = {
|
||||
|
||||
@ -6,7 +6,7 @@ import ForbiddenError from '@/shared/errors/forbidden';
|
||||
import ValidationError from '@/shared/errors/validation';
|
||||
import {
|
||||
assertAuthenticatedTenantUser,
|
||||
campusScope,
|
||||
campusDimensionScope,
|
||||
getCampusId,
|
||||
getOrganizationId,
|
||||
getOrganizationIdOrGlobal,
|
||||
@ -23,7 +23,6 @@ import {
|
||||
COMMUNICATION_MANAGER_ROLE_NAMES,
|
||||
COMMUNICATION_RECIPIENT_TYPES,
|
||||
COMMUNICATION_STATUSES,
|
||||
COMMUNICATION_TENANT_WIDE_ROLE_NAMES,
|
||||
DEFAULT_PARENT_MESSAGE_CATEGORY,
|
||||
PARENT_MESSAGE_CATEGORY_VALUES,
|
||||
type ParentMessageCategory,
|
||||
@ -165,7 +164,7 @@ class CommunicationsService {
|
||||
...orgFilter,
|
||||
...createdByFilter,
|
||||
audience: COMMUNICATION_AUDIENCES.GUARDIANS,
|
||||
...campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES),
|
||||
...campusDimensionScope(currentUser),
|
||||
...(filter.category ? { subject: filter.category } : {}),
|
||||
},
|
||||
include: [
|
||||
@ -259,7 +258,7 @@ class CommunicationsService {
|
||||
const result = await db.communication_events.findAndCountAll({
|
||||
where: {
|
||||
...orgFilter,
|
||||
...campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES),
|
||||
...campusDimensionScope(currentUser),
|
||||
...(filter.type ? { event_type: filter.type } : {}),
|
||||
},
|
||||
order: [
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Op } from 'sequelize';
|
||||
import db from '@/db/models';
|
||||
import { withTransaction } from '@/db/with-transaction';
|
||||
import ForbiddenError from '@/shared/errors/forbidden';
|
||||
@ -6,6 +7,7 @@ import {
|
||||
getOrganizationIdOrGlobal,
|
||||
getCampusId,
|
||||
assertAuthenticatedTenantUser,
|
||||
campusDimensionScope,
|
||||
hasRoleAccess,
|
||||
} from '@/services/shared/access';
|
||||
import { PERSONALITY_REPORT_ROLE_NAMES } from '@/shared/constants/personality';
|
||||
@ -152,7 +154,12 @@ class PersonalityQuizResultsService {
|
||||
],
|
||||
where: {
|
||||
...orgFilter,
|
||||
...(filter.campusId ? { campusId: filter.campusId } : {}),
|
||||
// School/campus isolation; an explicit campusId filter is intersected
|
||||
// (Op.and) with the scope so a school role cannot read another school.
|
||||
[Op.and]: [
|
||||
campusDimensionScope(currentUser),
|
||||
...(filter.campusId ? [{ campusId: filter.campusId }] : []),
|
||||
],
|
||||
},
|
||||
group: ['personality_type'],
|
||||
order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']],
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
getOrganizationIdOrGlobal,
|
||||
getCampusId,
|
||||
assertAuthenticatedTenantUser,
|
||||
campusDimensionScope,
|
||||
hasRoleAccess,
|
||||
getDisplayName,
|
||||
} from '@/services/shared/access';
|
||||
@ -90,7 +91,7 @@ class SafetyQuizResultsService {
|
||||
where: {
|
||||
...orgFilter,
|
||||
...(hasRoleAccess(currentUser, SAFETY_QUIZ_REPORT_ROLE_NAMES)
|
||||
? {}
|
||||
? campusDimensionScope(currentUser)
|
||||
: { userId: currentUser?.id ?? null }),
|
||||
...(filter.week_of ? { week_of: filter.week_of } : {}),
|
||||
},
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { Op, literal } from 'sequelize';
|
||||
import ForbiddenError from '@/shared/errors/forbidden';
|
||||
import { ROLE_SCOPES } from '@/shared/constants/roles';
|
||||
import type { CurrentUser } from '@/db/api/types';
|
||||
|
||||
/** UUIDs are inlined into a SQL literal subquery; reject anything that is not one. */
|
||||
const UUID_RE =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
|
||||
/**
|
||||
* Shared tenant/role access helpers used by the feature services. Centralizes
|
||||
* the (previously copy-pasted) organization/campus/role checks so every module
|
||||
@ -19,6 +25,21 @@ export function getCampusId(currentUser?: CurrentUser): string | null {
|
||||
return currentUser?.campusId || null;
|
||||
}
|
||||
|
||||
export function getSchoolId(currentUser?: CurrentUser): string | null {
|
||||
const staff = currentUser?.staff_user;
|
||||
if (Array.isArray(staff) && staff[0]?.schoolId) {
|
||||
return staff[0].schoolId;
|
||||
}
|
||||
return currentUser?.schoolId || null;
|
||||
}
|
||||
|
||||
/** The authorization scope of the user's role (`roles.scope`), if loaded. */
|
||||
export function getRoleScope(
|
||||
currentUser?: CurrentUser,
|
||||
): string | null | undefined {
|
||||
return currentUser?.app_role?.scope;
|
||||
}
|
||||
|
||||
export function getRoleName(
|
||||
currentUser?: CurrentUser,
|
||||
): string | null | undefined {
|
||||
@ -108,3 +129,57 @@ export function campusScope(
|
||||
const campusId = getCampusId(currentUser);
|
||||
return campusId ? { campusId } : {};
|
||||
}
|
||||
|
||||
/** Campus-dimension constraint for a campus-bearing table, keyed by scope. */
|
||||
export type CampusDimensionScope = {
|
||||
campusId?: string | { [Op.in]: ReturnType<typeof literal> };
|
||||
};
|
||||
|
||||
/**
|
||||
* The campus-dimension filter for the current user, derived from the role's
|
||||
* authorization scope (`roles.scope`) instead of per-feature "tenant-wide"
|
||||
* lists. ANDs together with the organization filter the caller already applies:
|
||||
*
|
||||
* - global / organization scope → `{}` (no campus narrowing; whole organization)
|
||||
* - school scope (Principal/Registrar) → `campusId IN (campuses of the user's
|
||||
* school)`, enforcing hard isolation between schools
|
||||
* - campus scope (Director/office_manager/teacher/support_staff) → `{ campusId }`
|
||||
*
|
||||
* Only valid for tables that carry a `campusId`. For school scope the campus set
|
||||
* is resolved with a correlated subquery so no extra round-trip or denormalized
|
||||
* `schoolId` column is needed (a campus belongs to exactly one school).
|
||||
*/
|
||||
export function campusDimensionScope(
|
||||
currentUser?: CurrentUser,
|
||||
): CampusDimensionScope {
|
||||
if (hasGlobalAccess(currentUser)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scope = getRoleScope(currentUser);
|
||||
|
||||
if (scope === ROLE_SCOPES.SCHOOL) {
|
||||
const schoolId = getSchoolId(currentUser);
|
||||
if (!schoolId) {
|
||||
return {};
|
||||
}
|
||||
if (!UUID_RE.test(schoolId)) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
return {
|
||||
campusId: {
|
||||
[Op.in]: literal(
|
||||
`(SELECT "id" FROM "campuses" WHERE "schoolId" = '${schoolId}' AND "deletedAt" IS NULL)`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === ROLE_SCOPES.CAMPUS) {
|
||||
const campusId = getCampusId(currentUser);
|
||||
return campusId ? { campusId } : {};
|
||||
}
|
||||
|
||||
// organization / external / guest: no campus-dimension narrowing here.
|
||||
return {};
|
||||
}
|
||||
|
||||
@ -16,28 +16,39 @@ const ALL_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES);
|
||||
* The roles each actor may create, re-assign, or delete on another user:
|
||||
* - `super_admin`: anyone.
|
||||
* - `system_admin`: anyone except `super_admin` / `system_admin`.
|
||||
* - `owner`: org/campus/external roles (not the system roles, not another owner).
|
||||
* - `owner`: org/school/campus/external roles (not the system roles, not another owner).
|
||||
* - `superintendent`: not `owner` / `superintendent` (per spec).
|
||||
* - `director`: campus + external roles only (not director/superintendent/owner/admins).
|
||||
* - `principal`: all of the school's staff (registrar + director + campus roles +
|
||||
* external), not org/system roles nor another principal.
|
||||
* - `registrar`: nobody (read-only/audit assistant).
|
||||
* - `director`: campus + external roles only (not director/principal/superintendent/owner/admins).
|
||||
* - everyone else: nobody.
|
||||
*/
|
||||
const MANAGEABLE_ROLES_BY_ACTOR: Record<RoleName, readonly RoleName[]> = {
|
||||
[ROLE_NAMES.SUPER_ADMIN]: ALL_ROLE_NAMES,
|
||||
[ROLE_NAMES.SYSTEM_ADMIN]: [
|
||||
ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF,
|
||||
ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,
|
||||
],
|
||||
[ROLE_NAMES.OWNER]: [
|
||||
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER,
|
||||
ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER,
|
||||
ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT,
|
||||
ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,
|
||||
],
|
||||
[ROLE_NAMES.SUPERINTENDENT]: [
|
||||
[ROLE_NAMES.OWNER]: [
|
||||
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.PRINCIPAL, ROLE_NAMES.REGISTRAR,
|
||||
ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER,
|
||||
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN,
|
||||
ROLE_NAMES.GUEST,
|
||||
],
|
||||
[ROLE_NAMES.SUPERINTENDENT]: [
|
||||
ROLE_NAMES.PRINCIPAL, ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF,
|
||||
ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,
|
||||
],
|
||||
[ROLE_NAMES.PRINCIPAL]: [
|
||||
ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER,
|
||||
ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT,
|
||||
ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,
|
||||
],
|
||||
[ROLE_NAMES.REGISTRAR]: [],
|
||||
[ROLE_NAMES.DIRECTOR]: [
|
||||
ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF,
|
||||
ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,
|
||||
|
||||
@ -6,12 +6,11 @@ import {
|
||||
STAFF_ATTENDANCE_MAX_LIMIT,
|
||||
STAFF_ATTENDANCE_REPORT_ROLE_NAMES,
|
||||
STAFF_ATTENDANCE_STATUSES,
|
||||
STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
|
||||
} from '@/shared/constants/staff-attendance';
|
||||
import { STAFF_STATUSES } from '@/shared/constants/staff';
|
||||
import {
|
||||
assertAuthenticatedTenantUser,
|
||||
campusScope,
|
||||
campusDimensionScope,
|
||||
hasRoleAccess,
|
||||
requireOrganizationId,
|
||||
requireUserId,
|
||||
@ -25,15 +24,17 @@ interface StaffAttendanceFilter {
|
||||
limit?: unknown;
|
||||
}
|
||||
|
||||
/** Restricts records to the staff member, their campus, or the whole tenant. */
|
||||
function visibilityScope(
|
||||
currentUser?: CurrentUser,
|
||||
): { userId?: string; campusId?: string } {
|
||||
/**
|
||||
* Restricts records to the staff member, or (for report roles) to the records
|
||||
* their scope allows: org-wide for owner/superintendent, the school's campuses
|
||||
* for principal/registrar, a single campus for director.
|
||||
*/
|
||||
function visibilityScope(currentUser?: CurrentUser) {
|
||||
if (!hasRoleAccess(currentUser, STAFF_ATTENDANCE_REPORT_ROLE_NAMES)) {
|
||||
return { userId: requireUserId(currentUser) };
|
||||
}
|
||||
|
||||
return campusScope(currentUser, STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES);
|
||||
return campusDimensionScope(currentUser);
|
||||
}
|
||||
|
||||
function dateFilter(filter: StaffAttendanceFilter): {
|
||||
@ -127,7 +128,7 @@ class StaffAttendanceService {
|
||||
where: {
|
||||
organizationId: requireOrganizationId(currentUser),
|
||||
status: STAFF_STATUSES.ACTIVE,
|
||||
...campusScope(currentUser, STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES),
|
||||
...campusDimensionScope(currentUser),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -4,11 +4,9 @@ import { withTransaction } from '@/db/with-transaction';
|
||||
import { resolvePagination } from '@/shared/constants/pagination';
|
||||
import ForbiddenError from '@/shared/errors/forbidden';
|
||||
import ValidationError from '@/shared/errors/validation';
|
||||
import { WALKTHROUGH_MANAGER_ROLE_NAMES } from '@/shared/constants/walkthrough';
|
||||
import {
|
||||
WALKTHROUGH_MANAGER_ROLE_NAMES,
|
||||
WALKTHROUGH_TENANT_WIDE_ROLE_NAMES,
|
||||
} from '@/shared/constants/walkthrough';
|
||||
import {
|
||||
campusDimensionScope,
|
||||
getOrganizationIdOrGlobal,
|
||||
hasGlobalAccess,
|
||||
hasRoleAccess,
|
||||
@ -120,17 +118,6 @@ function toDto(record: WalkthroughCheckins) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Restricts non-tenant-wide roles to their own campus. Uses the module-local
|
||||
* `getCampusId` (staff-campus only), so it stays local. */
|
||||
function campusScope(currentUser?: CurrentUser): { campusId?: string } {
|
||||
if (hasRoleAccess(currentUser, WALKTHROUGH_TENANT_WIDE_ROLE_NAMES)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const campusId = getCampusId(currentUser);
|
||||
return campusId ? { campusId } : {};
|
||||
}
|
||||
|
||||
class WalkthroughCheckinsService {
|
||||
static async list(filter: WalkthroughFilter, currentUser?: CurrentUser) {
|
||||
assertCanManage(currentUser);
|
||||
@ -142,7 +129,7 @@ class WalkthroughCheckinsService {
|
||||
const result = await db.walkthrough_checkins.findAndCountAll({
|
||||
where: {
|
||||
...orgFilter,
|
||||
...campusScope(currentUser),
|
||||
...campusDimensionScope(currentUser),
|
||||
...(filter.teacher_name ? { teacher_name: filter.teacher_name } : {}),
|
||||
},
|
||||
order: [
|
||||
@ -212,7 +199,7 @@ class WalkthroughCheckinsService {
|
||||
where: {
|
||||
id,
|
||||
...orgFilter,
|
||||
...campusScope(currentUser),
|
||||
...campusDimensionScope(currentUser),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import { ROLE_NAMES } from './roles';
|
||||
|
||||
export const CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([
|
||||
export const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SUPER_ADMIN,
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
]);
|
||||
|
||||
export const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([
|
||||
...CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
ROLE_NAMES.OFFICE_MANAGER,
|
||||
]);
|
||||
|
||||
@ -49,12 +49,6 @@ export const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
export const COMMUNICATION_TENANT_WIDE_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SUPER_ADMIN,
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
]);
|
||||
|
||||
@ -5,5 +5,6 @@ export const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
@ -5,5 +5,6 @@ export const FRAME_EDITOR_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
@ -5,5 +5,7 @@ export const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.REGISTRAR,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Authorization scope of a role (Workstream 3 §3.1). Determines the breadth of
|
||||
* a role's reach: platform-wide, a single organization, a single campus, the
|
||||
* external-user surface, or the unauthenticated guest. Stored on `roles.scope`.
|
||||
* a role's reach: platform-wide, a single organization, a single school (all its
|
||||
* campuses), a single campus, the external-user surface, or the unauthenticated
|
||||
* guest. Stored on `roles.scope`. Hierarchy: SYSTEM ⊃ ORGANIZATION ⊃ SCHOOL ⊃
|
||||
* CAMPUS.
|
||||
*/
|
||||
export const ROLE_SCOPES = Object.freeze({
|
||||
SYSTEM: 'system',
|
||||
ORGANIZATION: 'organization',
|
||||
SCHOOL: 'school',
|
||||
CAMPUS: 'campus',
|
||||
EXTERNAL: 'external',
|
||||
GUEST: 'guest',
|
||||
@ -18,16 +21,21 @@ export const ROLE_SCOPE_VALUES: readonly RoleScope[] = Object.freeze(
|
||||
);
|
||||
|
||||
/**
|
||||
* The 11 first-class platform roles (Workstream 3 §3.1). The stored `roles.name`
|
||||
* The 13 first-class platform roles (Workstream 3 §3.1). The stored `roles.name`
|
||||
* uses these stable machine values; `roles.scope` is the matching scope. `guest`
|
||||
* is the unauthenticated fallback (the seeded `Public` row), not an assignable
|
||||
* user role. `globalAccess` (system roles only) bypasses tenant filtering.
|
||||
* `principal`/`registrar` are the school-tier roles (Organization → School →
|
||||
* Campus): the Principal is the school head (full school access), the Registrar
|
||||
* is the Principal's read-only/audit assistant.
|
||||
*/
|
||||
export const ROLE_NAMES = Object.freeze({
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
SYSTEM_ADMIN: 'system_admin',
|
||||
OWNER: 'owner',
|
||||
SUPERINTENDENT: 'superintendent',
|
||||
PRINCIPAL: 'principal',
|
||||
REGISTRAR: 'registrar',
|
||||
DIRECTOR: 'director',
|
||||
OFFICE_MANAGER: 'office_manager',
|
||||
TEACHER: 'teacher',
|
||||
@ -51,6 +59,8 @@ export const ROLE_DEFINITIONS: readonly RoleDefinition[] = Object.freeze([
|
||||
{ name: ROLE_NAMES.SYSTEM_ADMIN, scope: ROLE_SCOPES.SYSTEM, globalAccess: true },
|
||||
{ name: ROLE_NAMES.OWNER, scope: ROLE_SCOPES.ORGANIZATION, globalAccess: false },
|
||||
{ name: ROLE_NAMES.SUPERINTENDENT, scope: ROLE_SCOPES.ORGANIZATION, globalAccess: false },
|
||||
{ name: ROLE_NAMES.PRINCIPAL, scope: ROLE_SCOPES.SCHOOL, globalAccess: false },
|
||||
{ name: ROLE_NAMES.REGISTRAR, scope: ROLE_SCOPES.SCHOOL, globalAccess: false },
|
||||
{ name: ROLE_NAMES.DIRECTOR, scope: ROLE_SCOPES.CAMPUS, globalAccess: false },
|
||||
{ name: ROLE_NAMES.OFFICE_MANAGER, scope: ROLE_SCOPES.CAMPUS, globalAccess: false },
|
||||
{ name: ROLE_NAMES.TEACHER, scope: ROLE_SCOPES.CAMPUS, globalAccess: false },
|
||||
|
||||
@ -5,5 +5,7 @@ export const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.REGISTRAR,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
@ -6,19 +6,37 @@ import {
|
||||
} from '@/shared/constants/users';
|
||||
|
||||
/**
|
||||
* RBAC seed fixtures (Workstream 4): one company, one campus (the `tigers`
|
||||
* product campus), staff covering every campus role, and exactly one loginable
|
||||
* user per stored role. Single source shared by the admin-user seeder (creates
|
||||
* the users), the user-roles seeder (assigns roles), and the rbac-fixtures
|
||||
* seeder (org/campus/staff links). Pre-launch — reset the DB and reseed.
|
||||
* RBAC seed fixtures (Workstream 4): one company, two schools, the six product
|
||||
* campuses, staff covering every campus role, and exactly one loginable user per
|
||||
* stored role. Single source shared by the admin-user seeder (creates the
|
||||
* users), the user-roles seeder (assigns roles), and the rbac-fixtures seeder
|
||||
* (org/school/campus/staff links). Pre-launch — reset the DB and reseed.
|
||||
*/
|
||||
|
||||
export const SEED_ORGANIZATION_ID = 'b1a7c0de-0000-4000-8000-000000000001';
|
||||
export const SEED_ORGANIZATION_NAME = 'Demo Academy';
|
||||
|
||||
/**
|
||||
* Two schools under the primary org (Organization → School → Campus). School 1
|
||||
* (North) owns the first three campuses (incl. the `tigers` campus the fixture
|
||||
* staff are on); School 2 (South) owns the rest — used to prove hard isolation
|
||||
* between schools. Every campus belongs to exactly one school.
|
||||
*/
|
||||
export const SEED_SCHOOL_ID = 'b1a7c0de-0000-4000-8000-000000000031';
|
||||
export const SEED_SCHOOL_NAME = 'Demo Academy North';
|
||||
export const SEED_SCHOOL_2_ID = 'b1a7c0de-0000-4000-8000-000000000032';
|
||||
export const SEED_SCHOOL_2_NAME = 'Demo Academy South';
|
||||
|
||||
/** The campus the fixture staff are assigned to (the seeded `tigers` campus). */
|
||||
export const SEED_CAMPUS_ID = PRODUCT_CAMPUS_SEED_ROWS[0].id;
|
||||
|
||||
/** Campus → school assignment (campus belongs to exactly one school). */
|
||||
export const SEED_SCHOOL_CAMPUS_IDS: Readonly<Record<string, readonly string[]>> =
|
||||
Object.freeze({
|
||||
[SEED_SCHOOL_ID]: PRODUCT_CAMPUS_SEED_ROWS.slice(0, 3).map((c) => c.id),
|
||||
[SEED_SCHOOL_2_ID]: PRODUCT_CAMPUS_SEED_ROWS.slice(3).map((c) => c.id),
|
||||
});
|
||||
|
||||
export type StaffType = 'teacher' | 'admin' | 'support';
|
||||
|
||||
export interface SeedFixtureUser {
|
||||
@ -31,8 +49,10 @@ export interface SeedFixtureUser {
|
||||
readonly role: RoleName;
|
||||
/** Uses `SEED_ADMIN_PASSWORD` (system roles) vs `SEED_USER_PASSWORD`. */
|
||||
readonly admin: boolean;
|
||||
/** Gets `organizationId` (org/campus/external roles; not the system roles). */
|
||||
/** Gets `organizationId` (org/school/campus/external roles; not the system roles). */
|
||||
readonly organization: boolean;
|
||||
/** Gets `schoolId` = `SEED_SCHOOL_ID` (school + campus + external roles in School 1). */
|
||||
readonly school: boolean;
|
||||
/** Gets `campusId` (campus + external roles). */
|
||||
readonly campus: boolean;
|
||||
/** When set, a staff profile is created with this `staff_type`. */
|
||||
@ -40,16 +60,18 @@ export interface SeedFixtureUser {
|
||||
}
|
||||
|
||||
export const SEED_FIXTURE_USERS: readonly SeedFixtureUser[] = [
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000010', email: 'admin@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Alex', lastName: 'Morgan', role: ROLE_NAMES.SUPER_ADMIN, admin: true, organization: false, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000011', email: 'system_admin@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Jordan', lastName: 'Chen', role: ROLE_NAMES.SYSTEM_ADMIN, admin: true, organization: false, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000012', email: 'owner@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Patricia', lastName: 'Hayes', role: ROLE_NAMES.OWNER, admin: false, organization: true, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000013', email: 'superintendent@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Michael', lastName: 'Torres', role: ROLE_NAMES.SUPERINTENDENT, admin: false, organization: true, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000014', email: 'director@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Sarah', lastName: 'Williams', role: ROLE_NAMES.DIRECTOR, admin: false, organization: true, campus: true, staffType: 'admin' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000015', email: 'office_manager@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Lisa', lastName: 'Park', role: ROLE_NAMES.OFFICE_MANAGER, admin: false, organization: true, campus: true, staffType: 'admin' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000016', email: 'teacher@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Emily', lastName: 'Johnson', role: ROLE_NAMES.TEACHER, admin: false, organization: true, campus: true, staffType: 'teacher' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000017', email: 'support_staff@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Marcus', lastName: 'Davis', role: ROLE_NAMES.SUPPORT_STAFF, admin: false, organization: true, campus: true, staffType: 'support' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000018', email: 'student@flatlogic.com', firstName: 'Emma', lastName: 'Clark', role: ROLE_NAMES.STUDENT, admin: false, organization: true, campus: true },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000019', email: 'guardian@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Robert', lastName: 'Clark', role: ROLE_NAMES.GUARDIAN, admin: false, organization: true, campus: true },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000010', email: 'admin@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Alex', lastName: 'Morgan', role: ROLE_NAMES.SUPER_ADMIN, admin: true, organization: false, school: false, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000011', email: 'system_admin@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Jordan', lastName: 'Chen', role: ROLE_NAMES.SYSTEM_ADMIN, admin: true, organization: false, school: false, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000012', email: 'owner@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Patricia', lastName: 'Hayes', role: ROLE_NAMES.OWNER, admin: false, organization: true, school: false, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000013', email: 'superintendent@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Michael', lastName: 'Torres', role: ROLE_NAMES.SUPERINTENDENT, admin: false, organization: true, school: false, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000021', email: 'principal@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Karen', lastName: 'Mitchell', role: ROLE_NAMES.PRINCIPAL, admin: false, organization: true, school: true, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000022', email: 'registrar@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Nicole', lastName: 'Adams', role: ROLE_NAMES.REGISTRAR, admin: false, organization: true, school: true, campus: false },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000014', email: 'director@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Sarah', lastName: 'Williams', role: ROLE_NAMES.DIRECTOR, admin: false, organization: true, school: true, campus: true, staffType: 'admin' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000015', email: 'office_manager@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Lisa', lastName: 'Park', role: ROLE_NAMES.OFFICE_MANAGER, admin: false, organization: true, school: true, campus: true, staffType: 'admin' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000016', email: 'teacher@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Emily', lastName: 'Johnson', role: ROLE_NAMES.TEACHER, admin: false, organization: true, school: true, campus: true, staffType: 'teacher' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000017', email: 'support_staff@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Marcus', lastName: 'Davis', role: ROLE_NAMES.SUPPORT_STAFF, admin: false, organization: true, school: true, campus: true, staffType: 'support' },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000018', email: 'student@flatlogic.com', firstName: 'Emma', lastName: 'Clark', role: ROLE_NAMES.STUDENT, admin: false, organization: true, school: true, campus: true },
|
||||
{ id: 'b1a7c0de-0000-4000-8000-000000000019', email: 'guardian@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Robert', lastName: 'Clark', role: ROLE_NAMES.GUARDIAN, admin: false, organization: true, school: true, campus: true },
|
||||
];
|
||||
|
||||
/**
|
||||
@ -70,6 +92,7 @@ export const SEED_SECONDARY_OWNER: SeedFixtureUser = {
|
||||
role: ROLE_NAMES.OWNER,
|
||||
admin: false,
|
||||
organization: true,
|
||||
school: false,
|
||||
campus: false,
|
||||
};
|
||||
|
||||
|
||||
@ -11,14 +11,10 @@ export const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.REGISTRAR,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
export const STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SUPER_ADMIN,
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
]);
|
||||
|
||||
export const STAFF_ATTENDANCE_DEFAULT_LIMIT = 90;
|
||||
export const STAFF_ATTENDANCE_MAX_LIMIT = 366;
|
||||
|
||||
@ -5,12 +5,6 @@ export const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
ROLE_NAMES.PRINCIPAL,
|
||||
ROLE_NAMES.DIRECTOR,
|
||||
]);
|
||||
|
||||
export const WALKTHROUGH_TENANT_WIDE_ROLE_NAMES = Object.freeze([
|
||||
ROLE_NAMES.SUPER_ADMIN,
|
||||
ROLE_NAMES.SYSTEM_ADMIN,
|
||||
ROLE_NAMES.OWNER,
|
||||
ROLE_NAMES.SUPERINTENDENT,
|
||||
]);
|
||||
|
||||
@ -31,6 +31,22 @@ Use `StatePanel` when a feature needs a standard loading, error, or empty block.
|
||||
|
||||
Feature-specific wrapper components may stay in their module folder when they carry product copy or workflow-specific props. Those wrappers should delegate the repeated shell to `StatePanel`.
|
||||
|
||||
## Loading States: PageSkeleton vs StatePanel
|
||||
|
||||
There are two loading scopes, and they must not be mixed within one navigation:
|
||||
|
||||
- **Page-scope load** — the whole page (or its primary content) is unavailable until the fetch resolves. Use `PageSkeleton` (`components/ui/page-skeleton.tsx`), early-returned from the page/wrapper. `ModuleRouteGuard` already renders the same `PageSkeleton` as the lazy-route Suspense fallback, so the chunk load and the data load read as **one continuous skeleton** instead of a skeleton that flips into a spinner. Do not use a `StatePanel` spinner for this.
|
||||
|
||||
```tsx
|
||||
if (page.isLoading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
```
|
||||
|
||||
- **Local/section load** — the page chrome (header, filters, interactive controls) renders immediately and only a secondary region is still fetching. Keep a `StatePanel` spinner (or a `Button` busy state) scoped to that region. Examples: a results grid below interactive filters, a secondary compliance panel, a per-tab fetch.
|
||||
|
||||
Rule of thumb: if loading hides everything behind a static header, it is page-scope → `PageSkeleton`. If interactive chrome is already usable while one area loads, it is local → `StatePanel`.
|
||||
|
||||
## Button Usage
|
||||
|
||||
Use `Button` for new clickable commands. Prefer `leadingIcon`, `loading`, and `loadingLabel` instead of duplicating inline spinner branches.
|
||||
@ -58,7 +74,7 @@ Use `ModuleHeader` for repeated module-level title blocks with a square icon and
|
||||
## Rules
|
||||
|
||||
- Keep UI variants and class maps in dedicated non-component files.
|
||||
- Use `StatePanel` for repeated loading, error, and empty panels instead of copying Tailwind containers.
|
||||
- Use `StatePanel` for repeated error/empty panels and **local/section** loading; use `PageSkeleton` for **page-scope** loading so it stays continuous with the route Suspense fallback (see Loading States above).
|
||||
- Use existing `Button`, `Input`, `Textarea`, `Select`, and `Table` primitives for new view code.
|
||||
- Use `NativeSelect` for simple select fields where native browser behavior is sufficient.
|
||||
- Use `ModuleHeader` for repeated page/module headings instead of duplicating title/icon markup.
|
||||
|
||||
@ -65,6 +65,10 @@ export function getAuthRoleLabel(role: UserRole): string {
|
||||
return 'Owner';
|
||||
case 'superintendent':
|
||||
return 'Superintendent';
|
||||
case 'principal':
|
||||
return 'Principal';
|
||||
case 'registrar':
|
||||
return 'Registrar';
|
||||
case 'director':
|
||||
return 'Director';
|
||||
case 'office_manager':
|
||||
|
||||
@ -3,6 +3,7 @@ import { CommunityFilters } from '@/components/community-service/CommunityFilter
|
||||
import { CommunityResults } from '@/components/community-service/CommunityResults';
|
||||
import { CommunityServiceHeader } from '@/components/community-service/CommunityServiceHeader';
|
||||
import { CommunityStatsGrid } from '@/components/community-service/CommunityStatsGrid';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
|
||||
interface CommunityServiceViewProps {
|
||||
@ -10,24 +11,23 @@ interface CommunityServiceViewProps {
|
||||
}
|
||||
|
||||
export function CommunityServiceView({ workflow }: CommunityServiceViewProps) {
|
||||
if (workflow.isLoading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CommunityServiceHeader />
|
||||
{workflow.isLoading && (
|
||||
<StatePanel tone="slate" loading>
|
||||
Loading community organizations...
|
||||
</StatePanel>
|
||||
)}
|
||||
{workflow.error && (
|
||||
<StatePanel tone="red" role="alert">
|
||||
Community organizations could not be loaded from the backend.
|
||||
</StatePanel>
|
||||
)}
|
||||
{!workflow.isLoading && !workflow.error && (
|
||||
{!workflow.error && (
|
||||
<>
|
||||
<CommunityFilters workflow={workflow} />
|
||||
<CommunityStatsGrid workflow={workflow} />
|
||||
<CommunityResults workflow={workflow} />
|
||||
<CommunityFilters workflow={workflow} />
|
||||
<CommunityStatsGrid workflow={workflow} />
|
||||
<CommunityResults workflow={workflow} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@ import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQu
|
||||
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
|
||||
import { DirectorRecentFramePanel } from '@/components/director-dashboard/DirectorRecentFramePanel';
|
||||
import { DirectorRiskList } from '@/components/director-dashboard/DirectorRiskList';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
||||
|
||||
@ -21,11 +22,7 @@ export function DirectorDashboardView({
|
||||
const errorMessage = getOptionalErrorMessage(page.error);
|
||||
|
||||
if (page.isLoading) {
|
||||
return (
|
||||
<StatePanel tone="violet" alignment="center" loading className="my-20">
|
||||
Loading director dashboard...
|
||||
</StatePanel>
|
||||
);
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
|
||||
@ -2,16 +2,6 @@ import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import type { FrameModuleViewProps } from '@/components/frame/types';
|
||||
|
||||
export function FrameLoadingPanel() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<StatePanel tone="violet" alignment="center" loading className="border-0 bg-transparent p-0">
|
||||
Loading F.R.A.M.E. entries...
|
||||
</StatePanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrameStatusPanel({ workflow }: FrameModuleViewProps) {
|
||||
const errorMessage = getOptionalErrorMessage(workflow.error);
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { TimerDisplay } from '@/components/classroom-timer/TimerDisplay';
|
||||
import { TimerProjectionView } from '@/components/classroom-timer/TimerProjectionView';
|
||||
import { TimerSettingsPanel } from '@/components/classroom-timer/TimerSettingsPanel';
|
||||
import { TimerTips } from '@/components/classroom-timer/TimerTips';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
||||
import type { UserRole } from '@/shared/types/app';
|
||||
@ -19,11 +20,7 @@ const ClassroomTimer = ({ userRole }: ClassroomTimerProps) => {
|
||||
const contentErrorMessage = getOptionalErrorMessage(timer.state.contentError);
|
||||
|
||||
if (timer.state.isContentLoading) {
|
||||
return (
|
||||
<StatePanel tone="cyan" loading>
|
||||
Loading classroom timer content...
|
||||
</StatePanel>
|
||||
);
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (contentErrorMessage || !timer.state.selectedBackground || !timer.state.selectedSound) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useFrameModule } from '@/business/frame/hooks';
|
||||
import { FrameModuleView } from '@/components/frame/FrameModuleView';
|
||||
import { FrameLoadingPanel } from '@/components/frame/FrameStatusPanel';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import type { UserRole } from '@/shared/types/app';
|
||||
|
||||
interface FrameModuleProps {
|
||||
@ -12,7 +12,7 @@ const FrameModule = ({ userRole, userName }: FrameModuleProps) => {
|
||||
const workflow = useFrameModule(userRole, userName);
|
||||
|
||||
if (workflow.isLoading) {
|
||||
return <FrameLoadingPanel />;
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return <FrameModuleView workflow={workflow} />;
|
||||
|
||||
@ -2,6 +2,7 @@ import type { PersonalityQuizWorkflow } from '@/components/personality-quiz/type
|
||||
import { PersonalityQuizIntro } from '@/components/personality-quiz/PersonalityQuizIntro';
|
||||
import { PersonalityQuizQuestionView } from '@/components/personality-quiz/PersonalityQuizQuestionView';
|
||||
import { PersonalityResultView } from '@/components/personality-quiz/PersonalityResultView';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
|
||||
interface PersonalityQuizViewProps {
|
||||
@ -18,11 +19,7 @@ export function PersonalityQuizView({
|
||||
isSaving,
|
||||
}: PersonalityQuizViewProps) {
|
||||
if (workflow.isContentLoading) {
|
||||
return (
|
||||
<StatePanel tone="violet" size="lg" alignment="center" loading>
|
||||
Loading personality assessment...
|
||||
</StatePanel>
|
||||
);
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (workflow.contentError) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Shield } from 'lucide-react';
|
||||
|
||||
import type { SafetyQuizPage } from '@/business/safety-quiz/types';
|
||||
import { ModuleHeader } from '@/components/ui/module-header';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
||||
import { SafetyQuizCompliancePanel } from '@/components/safety-quiz/SafetyQuizCompliancePanel';
|
||||
@ -17,6 +18,12 @@ interface SafetyQuizViewProps {
|
||||
}
|
||||
|
||||
export function SafetyQuizView({ page }: SafetyQuizViewProps) {
|
||||
// Page-scope load: keep the route skeleton continuous instead of swapping to a
|
||||
// spinner. The compliance panel keeps its own local spinner (secondary data).
|
||||
if (page.isQuizLoading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
const quizConfigurationError = getSafetyQuizConfigurationError(page.quiz);
|
||||
|
||||
return (
|
||||
@ -77,14 +84,6 @@ function SafetyQuizMainPanel({ page }: SafetyQuizMainPanelProps) {
|
||||
const quizConfigurationError = getSafetyQuizConfigurationError(quiz);
|
||||
const question = quiz?.questions[page.currentQuestionIndex] ?? null;
|
||||
|
||||
if (page.isQuizLoading) {
|
||||
return (
|
||||
<StatePanel tone="sky" alignment="center" loading className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border-slate-700/40 py-16">
|
||||
Loading safety quiz...
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (quizErrorMessage) {
|
||||
return (
|
||||
<StatePanel tone="red" role="alert">
|
||||
|
||||
@ -17,7 +17,9 @@ const ROLE_OPTIONS: readonly {
|
||||
{ value: 'teacher', label: 'Teacher', desc: 'Classroom educator', color: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', icon: <PeopleIcon /> },
|
||||
{ value: 'support_staff', label: 'Support Staff', desc: 'Paraprofessional', color: 'border-blue-500/30 bg-blue-500/10 text-blue-400', icon: <PeopleIcon /> },
|
||||
{ value: 'office_manager', label: 'Office Manager', desc: 'Administrative staff', color: 'border-amber-500/30 bg-amber-500/10 text-amber-400', icon: <OfficeIcon /> },
|
||||
{ value: 'registrar', label: 'Registrar', desc: 'School records & audit', color: 'border-violet-500/30 bg-violet-500/10 text-violet-400', icon: <OfficeIcon /> },
|
||||
{ value: 'director', label: 'Director', desc: 'Campus leadership', color: 'border-purple-500/30 bg-purple-500/10 text-purple-400', icon: <Shield size={24} /> },
|
||||
{ value: 'principal', label: 'Principal', desc: 'School leadership', color: 'border-fuchsia-500/30 bg-fuchsia-500/10 text-fuchsia-400', icon: <Shield size={24} /> },
|
||||
{ value: 'superintendent', label: 'Superintendent', desc: 'District-wide oversight', color: 'border-rose-500/30 bg-rose-500/10 text-rose-400', icon: <DistrictIcon /> },
|
||||
];
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { VocationalLoadingState } from '@/components/vocational-opportunities/Vo
|
||||
import { VocationalPreSearchState } from '@/components/vocational-opportunities/VocationalPreSearchState';
|
||||
import { VocationalResults } from '@/components/vocational-opportunities/VocationalResults';
|
||||
import { VocationalZipSearch } from '@/components/vocational-opportunities/VocationalZipSearch';
|
||||
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
||||
import { StatePanel } from '@/components/ui/state-panel';
|
||||
|
||||
interface VocationalOpportunitiesViewProps {
|
||||
@ -11,20 +12,19 @@ interface VocationalOpportunitiesViewProps {
|
||||
}
|
||||
|
||||
export function VocationalOpportunitiesView({ workflow }: VocationalOpportunitiesViewProps) {
|
||||
if (workflow.isLoading) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<VocationalHeader />
|
||||
{workflow.isLoading && (
|
||||
<StatePanel tone="sky" loading>
|
||||
Loading vocational opportunities...
|
||||
</StatePanel>
|
||||
)}
|
||||
{workflow.error && (
|
||||
<StatePanel tone="red" role="alert">
|
||||
Vocational opportunities could not be loaded from the backend.
|
||||
</StatePanel>
|
||||
)}
|
||||
{!workflow.isLoading && !workflow.error && (
|
||||
{!workflow.error && (
|
||||
<>
|
||||
<VocationalZipSearch workflow={workflow} />
|
||||
<VocationalLoadingState workflow={workflow} />
|
||||
|
||||
@ -21,8 +21,9 @@ describe('zoneCheckins API', () => {
|
||||
|
||||
it('fetches today zone check-in status', async () => {
|
||||
const todayCheckin: ZoneCheckinTodayDto = {
|
||||
date: '2026-06-12',
|
||||
zone: 'green',
|
||||
checkedInAt: '2026-06-12T10:00:00Z',
|
||||
isCheckedInToday: true,
|
||||
};
|
||||
apiRequestMock.mockResolvedValueOnce(todayCheckin);
|
||||
|
||||
@ -33,8 +34,9 @@ describe('zoneCheckins API', () => {
|
||||
|
||||
it('checks in to a zone', async () => {
|
||||
const todayCheckin: ZoneCheckinTodayDto = {
|
||||
date: '2026-06-12',
|
||||
zone: 'blue',
|
||||
checkedInAt: '2026-06-12T10:30:00Z',
|
||||
isCheckedInToday: true,
|
||||
};
|
||||
apiRequestMock.mockResolvedValueOnce(todayCheckin);
|
||||
|
||||
@ -48,8 +50,9 @@ describe('zoneCheckins API', () => {
|
||||
|
||||
it('clears today zone check-in', async () => {
|
||||
const clearedCheckin: ZoneCheckinTodayDto = {
|
||||
date: '2026-06-12',
|
||||
zone: null,
|
||||
checkedInAt: null,
|
||||
isCheckedInToday: false,
|
||||
};
|
||||
apiRequestMock.mockResolvedValueOnce(clearedCheckin);
|
||||
|
||||
|
||||
@ -9,8 +9,12 @@ const ADMIN: readonly UserRole[] = [
|
||||
'owner',
|
||||
'superintendent',
|
||||
];
|
||||
// School-tier leadership (Principal = full access, Registrar = read-only audit).
|
||||
// Both see every school surface; the backend enforces registrar's read-only.
|
||||
const SCHOOL_LEADERSHIP: readonly UserRole[] = ['principal', 'registrar'];
|
||||
const ALL_STAFF: readonly UserRole[] = [
|
||||
...ADMIN,
|
||||
...SCHOOL_LEADERSHIP,
|
||||
'director',
|
||||
'office_manager',
|
||||
'teacher',
|
||||
@ -19,14 +23,24 @@ const ALL_STAFF: readonly UserRole[] = [
|
||||
// Campus modules office_manager does not get (classroom/instructional tools).
|
||||
const STAFF_NO_OFFICE: readonly UserRole[] = [
|
||||
...ADMIN,
|
||||
...SCHOOL_LEADERSHIP,
|
||||
'director',
|
||||
'teacher',
|
||||
'support_staff',
|
||||
];
|
||||
// Parent communication: teaching staff + admin (no support/office).
|
||||
const PARENT_COMM: readonly UserRole[] = [...ADMIN, 'director', 'teacher'];
|
||||
// Director-only surfaces (dashboard, walkthrough).
|
||||
const DIRECTOR_ONLY: readonly UserRole[] = [...ADMIN, 'director'];
|
||||
// Parent communication: teaching staff + admin + school leadership (no support/office).
|
||||
const PARENT_COMM: readonly UserRole[] = [
|
||||
...ADMIN,
|
||||
...SCHOOL_LEADERSHIP,
|
||||
'director',
|
||||
'teacher',
|
||||
];
|
||||
// Leadership surfaces (dashboard, walkthrough): admin + school leadership + director.
|
||||
const DIRECTOR_ONLY: readonly UserRole[] = [
|
||||
...ADMIN,
|
||||
...SCHOOL_LEADERSHIP,
|
||||
'director',
|
||||
];
|
||||
// External-facing pages: all staff plus students and guardians.
|
||||
const EXTERNAL: readonly UserRole[] = [...ALL_STAFF, 'student', 'guardian'];
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@ export const USER_ROLE_VALUES: readonly UserRole[] = [
|
||||
'system_admin',
|
||||
'owner',
|
||||
'superintendent',
|
||||
'principal',
|
||||
'registrar',
|
||||
'director',
|
||||
'office_manager',
|
||||
'teacher',
|
||||
|
||||
@ -5,6 +5,8 @@ export const TOP_BAR_ROLE_BADGE_CLASSES: Record<UserRole, { readonly color: stri
|
||||
system_admin: { color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20' },
|
||||
owner: { color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/15 border-fuchsia-500/20' },
|
||||
superintendent: { color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20' },
|
||||
principal: { color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/15 border-fuchsia-500/20' },
|
||||
registrar: { color: 'text-violet-400', bg: 'bg-violet-500/15 border-violet-500/20' },
|
||||
director: { color: 'text-purple-400', bg: 'bg-purple-500/15 border-purple-500/20' },
|
||||
office_manager: { color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/20' },
|
||||
teacher: { color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/20' },
|
||||
|
||||
@ -3,6 +3,8 @@ export type UserRole =
|
||||
| 'system_admin'
|
||||
| 'owner'
|
||||
| 'superintendent'
|
||||
| 'principal'
|
||||
| 'registrar'
|
||||
| 'director'
|
||||
| 'office_manager'
|
||||
| 'teacher'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user