added school tenant

This commit is contained in:
Dmitri 2026-06-12 14:36:35 +02:00
parent f0d7d2a443
commit 768a13ce29
44 changed files with 692 additions and 168 deletions

View File

@ -53,6 +53,8 @@ export interface CurrentUser {
app_role?: { app_role?: {
globalAccess?: boolean | null; globalAccess?: boolean | null;
name?: string | 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. */ /** Present on the loaded role instance attached to the request. */
getPermissions?: () => Promise<PermissionLike[]>; getPermissions?: () => Promise<PermissionLike[]>;
} | null; } | null;
@ -69,8 +71,11 @@ export interface CurrentUser {
email?: string | null; email?: string | null;
campusId?: string | null; campusId?: string | null;
campus?: { code?: string | null; name?: string | null } | null; campus?: { code?: string | null; name?: string | null } | null;
schoolId?: string | null;
school?: { id?: string | null; name?: string | null } | null;
staff_user?: Array<{ staff_user?: Array<{
campusId?: string | null; campusId?: string | null;
schoolId?: string | null;
staff_type?: string | null; staff_type?: string | null;
campus?: { code?: string | null; name?: string | null } | null; campus?: { code?: string | null; name?: string | null } | null;
}> | null; }> | null;

View 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');
}
},
};

View File

@ -17,6 +17,7 @@ import type { AttendanceSessions } from './attendance_sessions';
import type { Classes } from './classes'; import type { Classes } from './classes';
import type { Messages } from './messages'; import type { Messages } from './messages';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Schools } from './schools';
import type { Staff } from './staff'; import type { Staff } from './staff';
import type { Timetables } from './timetables'; import type { Timetables } from './timetables';
import type { Users } from './users'; import type { Users } from './users';
@ -44,6 +45,8 @@ export class Campuses extends Model<
declare active: CreationOptional<boolean>; declare active: CreationOptional<boolean>;
declare importHash: CreationOptional<string | null>; declare importHash: CreationOptional<string | null>;
declare organizationId: 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 createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>; declare updatedById: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>; declare createdAt: CreationOptional<Date>;
@ -63,6 +66,8 @@ export class Campuses extends Model<
declare setMessages_campus: HasManySetAssociationsMixin<Messages, string>; declare setMessages_campus: HasManySetAssociationsMixin<Messages, string>;
declare getOrganization: BelongsToGetAssociationMixin<Organizations>; declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>; declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
declare getSchool: BelongsToGetAssociationMixin<Schools>;
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>; declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>; declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>; declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
@ -105,6 +110,12 @@ export class Campuses extends Model<
constraints: false, 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: 'createdBy' });
db.campuses.belongsTo(db.users, { as: 'updatedBy' }); db.campuses.belongsTo(db.users, { as: 'updatedBy' });
} }
@ -157,6 +168,7 @@ export default function (sequelize: Sequelize): typeof Campuses {
unique: true, unique: true,
}, },
organizationId: { type: DataTypes.UUID, allowNull: true }, organizationId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE }, createdAt: { type: DataTypes.DATE },

View File

@ -34,6 +34,7 @@ import policy_acknowledgments from './policy_acknowledgments';
import policy_documents from './policy_documents'; import policy_documents from './policy_documents';
import roles from './roles'; import roles from './roles';
import safety_quiz_results from './safety_quiz_results'; import safety_quiz_results from './safety_quiz_results';
import schools from './schools';
import staff from './staff'; import staff from './staff';
import staff_attendance_records from './staff_attendance_records'; import staff_attendance_records from './staff_attendance_records';
import subjects from './subjects'; import subjects from './subjects';
@ -129,6 +130,7 @@ const models = {
policy_documents: policy_documents(sequelize), policy_documents: policy_documents(sequelize),
roles: roles(sequelize), roles: roles(sequelize),
safety_quiz_results: safety_quiz_results(sequelize), safety_quiz_results: safety_quiz_results(sequelize),
schools: schools(sequelize),
staff: staff(sequelize), staff: staff(sequelize),
staff_attendance_records: staff_attendance_records(sequelize), staff_attendance_records: staff_attendance_records(sequelize),
subjects: subjects(sequelize), subjects: subjects(sequelize),

View File

@ -110,6 +110,14 @@ export class Organizations extends Model<
constraints: false, constraints: false,
}); });
db.organizations.hasMany(db.schools, {
as: 'schools_organization',
foreignKey: {
name: 'organizationId',
},
constraints: false,
});
db.organizations.hasMany(db.academic_years, { db.organizations.hasMany(db.academic_years, {
as: 'academic_years_organization', as: 'academic_years_organization',

View 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;
}

View File

@ -20,6 +20,7 @@ import type { ClassSubjects } from './class_subjects';
import type { Classes } from './classes'; import type { Classes } from './classes';
import type { File } from './file'; import type { File } from './file';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Schools } from './schools';
import type { Users } from './users'; import type { Users } from './users';
export class Staff extends Model< export class Staff extends Model<
@ -38,6 +39,8 @@ export class Staff extends Model<
declare deletedAt: CreationOptional<Date | null>; declare deletedAt: CreationOptional<Date | null>;
declare campusId: CreationOptional<string | null>; declare campusId: CreationOptional<string | null>;
declare organizationId: 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 userId: CreationOptional<string | null>;
declare createdById: CreationOptional<string | null>; declare createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>; declare updatedById: CreationOptional<string | null>;
@ -53,6 +56,8 @@ export class Staff extends Model<
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>; declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
declare getCampus: BelongsToGetAssociationMixin<Campuses>; declare getCampus: BelongsToGetAssociationMixin<Campuses>;
declare setCampus: BelongsToSetAssociationMixin<Campuses, string>; declare setCampus: BelongsToSetAssociationMixin<Campuses, string>;
declare getSchool: BelongsToGetAssociationMixin<Schools>;
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
// Eager-loaded association (populated by `include`). // Eager-loaded association (populated by `include`).
declare campus?: NonAttribute<Campuses>; declare campus?: NonAttribute<Campuses>;
declare getUser: BelongsToGetAssociationMixin<Users>; declare getUser: BelongsToGetAssociationMixin<Users>;
@ -141,6 +146,14 @@ export class Staff extends Model<
constraints: false, constraints: false,
}); });
db.staff.belongsTo(db.schools, {
as: 'school',
foreignKey: {
name: 'schoolId',
},
constraints: false,
});
db.staff.belongsTo(db.users, { db.staff.belongsTo(db.users, {
as: 'user', as: 'user',
foreignKey: { foreignKey: {
@ -250,6 +263,7 @@ status: {
deletedAt: { type: DataTypes.DATE }, deletedAt: { type: DataTypes.DATE },
campusId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true }, organizationId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
userId: { type: DataTypes.UUID, allowNull: true }, userId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true },

View File

@ -29,6 +29,7 @@ import type { Messages } from './messages';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Permissions } from './permissions'; import type { Permissions } from './permissions';
import type { Roles } from './roles'; import type { Roles } from './roles';
import type { Schools } from './schools';
import type { Staff } from './staff'; import type { Staff } from './staff';
const providers = config.providers; const providers = config.providers;
@ -55,6 +56,8 @@ export class Users extends Model<
declare organizationId: CreationOptional<string | null>; declare organizationId: CreationOptional<string | null>;
/** Campus scope for campus-bound roles (Workstream 3 §3.1). Nullable. */ /** Campus scope for campus-bound roles (Workstream 3 §3.1). Nullable. */
declare campusId: CreationOptional<string | null>; 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 createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>; declare updatedById: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>; declare createdAt: CreationOptional<Date>;
@ -82,6 +85,8 @@ export class Users extends Model<
declare setOrganizations: BelongsToSetAssociationMixin<Organizations, string>; declare setOrganizations: BelongsToSetAssociationMixin<Organizations, string>;
declare getCampus: BelongsToGetAssociationMixin<Campuses>; declare getCampus: BelongsToGetAssociationMixin<Campuses>;
declare setCampus: BelongsToSetAssociationMixin<Campuses, string>; declare setCampus: BelongsToSetAssociationMixin<Campuses, string>;
declare getSchool: BelongsToGetAssociationMixin<Schools>;
declare setSchool: BelongsToSetAssociationMixin<Schools, string>;
declare getAvatar: HasManyGetAssociationsMixin<File>; declare getAvatar: HasManyGetAssociationsMixin<File>;
declare setAvatar: HasManySetAssociationsMixin<File, string>; declare setAvatar: HasManySetAssociationsMixin<File, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>; declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
@ -148,6 +153,14 @@ export class Users extends Model<
constraints: false, constraints: false,
}); });
db.users.belongsTo(db.schools, {
as: 'school',
foreignKey: {
name: 'schoolId',
},
constraints: false,
});
db.users.hasMany(db.file, { db.users.hasMany(db.file, {
as: 'avatar', as: 'avatar',
foreignKey: 'belongsToId', foreignKey: 'belongsToId',
@ -238,6 +251,7 @@ export default function (sequelize: Sequelize): typeof Users {
}, },
organizationId: { type: DataTypes.UUID, allowNull: true }, organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE }, createdAt: { type: DataTypes.DATE },

View File

@ -11,6 +11,7 @@ import {
MODULE_READ_INSTRUCTIONAL, MODULE_READ_INSTRUCTIONAL,
MODULE_READ_PARENT_COMM, MODULE_READ_PARENT_COMM,
MODULE_READ_EXTERNAL, MODULE_READ_EXTERNAL,
MODULE_READ_DIRECTOR,
MODULE_ACTIONS, MODULE_ACTIONS,
MODULE_PERMISSIONS, MODULE_PERMISSIONS,
} from '@/shared/constants/product-permissions'; } from '@/shared/constants/product-permissions';
@ -45,15 +46,19 @@ const PERMISSION_ENTITIES = [
const CRUD_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE']; const CRUD_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE'];
const EXTRA_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH']; 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[] = [ const FULL_ACCESS_ROLES: readonly RoleName[] = [
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
// School head: full access, constrained to the school by tenant scoping.
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR, ROLE_NAMES.DIRECTOR,
]; ];
/** Roles granted read-only access to tenant resources. */ /** Roles granted read-only access to tenant resources. */
const READ_ONLY_ROLES: readonly RoleName[] = [ 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.OFFICE_MANAGER,
ROLE_NAMES.TEACHER, ROLE_NAMES.TEACHER,
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.SUPPORT_STAFF,
@ -77,6 +82,16 @@ const EXTERNAL_ROLES: readonly RoleName[] = [
* `student`/`guardian` get only the external pages. * `student`/`guardian` get only the external pages.
*/ */
const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> = { 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]: [ [ROLE_NAMES.OFFICE_MANAGER]: [
...MODULE_READ_ALL_STAFF, ...MODULE_READ_ALL_STAFF,
...MODULE_READ_EXTERNAL, ...MODULE_READ_EXTERNAL,

View File

@ -12,17 +12,24 @@ import {
SEED_ORGANIZATION_2_NAME, SEED_ORGANIZATION_2_NAME,
SEED_SECONDARY_OWNER, SEED_SECONDARY_OWNER,
SEED_CAMPUS_ID, SEED_CAMPUS_ID,
SEED_SCHOOL_ID,
SEED_SCHOOL_NAME,
SEED_SCHOOL_2_ID,
SEED_SCHOOL_2_NAME,
SEED_SCHOOL_CAMPUS_IDS,
SEED_FIXTURE_USERS, SEED_FIXTURE_USERS,
} from '@/shared/constants/seed-fixtures'; } from '@/shared/constants/seed-fixtures';
import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses'; import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses';
import type { Organizations } from '@/db/models/organizations'; import type { Organizations } from '@/db/models/organizations';
import type { Schools } from '@/db/models/schools';
import type { Staff } from '@/db/models/staff'; import type { Staff } from '@/db/models/staff';
/** /**
* RBAC fixture links (Workstream 4): one company that owns the seeded campuses, * RBAC fixture links (Workstream 4): one company that owns two schools and the
* the per-role users tied to the company/campus, and staff profiles covering * seeded campuses (each campus assigned to one school), the per-role users tied
* every campus staff role on the `tigers` campus. Runs after the user and role * to the org/school/campus, and staff profiles covering every campus staff role
* seeders. Idempotent and reversible. * 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 campusIds = PRODUCT_CAMPUS_SEED_ROWS.map((campus) => campus.id);
const staffFixtures = SEED_FIXTURE_USERS.filter((user) => user.staffType); 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( await queryInterface.sequelize.query(
`UPDATE "campuses" SET "organizationId" = :org WHERE "id" IN (:ids)`, `UPDATE "campuses" SET "organizationId" = :org WHERE "id" IN (:ids)`,
{ replacements: { org: SEED_ORGANIZATION_ID, ids: campusIds } }, { 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) { for (const user of SEED_FIXTURE_USERS) {
await queryInterface.sequelize.query( 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: { replacements: {
org: user.organization ? SEED_ORGANIZATION_ID : null, org: user.organization ? SEED_ORGANIZATION_ID : null,
school: user.school ? SEED_SCHOOL_ID : null,
campus: user.campus ? SEED_CAMPUS_ID : null, campus: user.campus ? SEED_CAMPUS_ID : null,
id: user.id, 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<{ const existingStaff = await queryInterface.sequelize.query<{
userId: string; userId: string;
}>(`SELECT "userId" FROM staff WHERE "userId" IN (:ids)`, { }>(`SELECT "userId" FROM staff WHERE "userId" IN (:ids)`, {
@ -100,6 +134,7 @@ export default {
staff_type: user.staffType ?? null, staff_type: user.staffType ?? null,
status: 'active', status: 'active',
organizationId: SEED_ORGANIZATION_ID, organizationId: SEED_ORGANIZATION_ID,
schoolId: user.school ? SEED_SCHOOL_ID : null,
campusId: SEED_CAMPUS_ID, campusId: SEED_CAMPUS_ID,
userId: user.id, userId: user.id,
createdAt, createdAt,
@ -118,7 +153,7 @@ export default {
{}, {},
); );
await queryInterface.sequelize.query( 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: { replacements: {
ids: [ ids: [
@ -129,9 +164,14 @@ export default {
}, },
); );
await queryInterface.sequelize.query( 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 } }, { replacements: { ids: campusIds } },
); );
await queryInterface.bulkDelete(
'schools',
{ id: { [Op.in]: [SEED_SCHOOL_ID, SEED_SCHOOL_2_ID] } },
{},
);
await queryInterface.bulkDelete( await queryInterface.bulkDelete(
'organizations', 'organizations',
{ id: { [Op.in]: [SEED_ORGANIZATION_ID, SEED_ORGANIZATION_2_ID] } }, { id: { [Op.in]: [SEED_ORGANIZATION_ID, SEED_ORGANIZATION_2_ID] } },

View File

@ -1,5 +1,5 @@
import { clampLimit, requiredIsoDate } from '@/services/shared/validate'; import { clampLimit, requiredIsoDate } from '@/services/shared/validate';
import { Op } from 'sequelize'; import { Op, literal } from 'sequelize';
import db from '@/db/models'; import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction'; import { withTransaction } from '@/db/with-transaction';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
@ -8,9 +8,9 @@ import {
CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_DEFAULT_LIMIT,
CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES,
CAMPUS_ATTENDANCE_MAX_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT,
CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
normalizeCampusKey, normalizeCampusKey,
} from '@/shared/constants/campus-attendance'; } from '@/shared/constants/campus-attendance';
import { ROLE_SCOPES } from '@/shared/constants/roles';
import { resolvePagination } from '@/shared/constants/pagination'; import { resolvePagination } from '@/shared/constants/pagination';
import type { CampusAttendanceConfig } from '@/db/models/campus_attendance_config'; import type { CampusAttendanceConfig } from '@/db/models/campus_attendance_config';
import type { CampusAttendanceSummaries } from '@/db/models/campus_attendance_summaries'; import type { CampusAttendanceSummaries } from '@/db/models/campus_attendance_summaries';
@ -23,12 +23,19 @@ import type {
import { import {
assertAuthenticatedTenantUser, assertAuthenticatedTenantUser,
getCampusId, getCampusId,
getRoleScope,
getSchoolId,
hasGlobalAccess,
hasRoleAccess, hasRoleAccess,
requireOrganizationId, requireOrganizationId,
requireUserId, requireUserId,
getDisplayName, getDisplayName,
} from '@/services/shared/access'; } 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 { function getCurrentUserCampusKey(currentUser?: CurrentUser): string | null {
const staff = currentUser?.staff_user; const staff = currentUser?.staff_user;
const staffProfile = Array.isArray(staff) ? staff[0] : null; const staffProfile = Array.isArray(staff) ? staff[0] : null;
@ -66,16 +73,74 @@ function campusKeyFromRoute(value: unknown): string {
return campusKey; 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, campusKey: string,
currentUser?: CurrentUser, currentUser?: CurrentUser,
): void { ): Promise<void> {
if (hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES)) { if (isOrgWide(currentUser)) {
return; 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) { if (currentCampusKey && currentCampusKey === campusKey) {
return; return;
} }
@ -83,22 +148,31 @@ function assertCanAccessCampusKey(
throw new ForbiddenError(); throw new ForbiddenError();
} }
/** Resolves the campus_key scope, asserting access along the way. */ /** Resolves the campus_key scope by tier, asserting access along the way. */
function campusScope( async function campusScope(
filter: CampusAttendanceFilter, filter: CampusAttendanceFilter,
currentUser?: CurrentUser, currentUser?: CurrentUser,
): { campus_key?: string } { ): Promise<{ campus_key?: string | { [Op.in]: ReturnType<typeof literal> } }> {
const requestedCampusKey = normalizeCampusKey(filter?.campusKey); const requestedCampusKey = normalizeCampusKey(filter?.campusKey);
if (requestedCampusKey) { if (requestedCampusKey) {
assertCanAccessCampusKey(requestedCampusKey, currentUser); await assertCanAccessCampusKey(requestedCampusKey, currentUser);
return { campus_key: requestedCampusKey }; return { campus_key: requestedCampusKey };
} }
if (hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES)) { if (isOrgWide(currentUser)) {
return {}; 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); const currentCampusKey = getCurrentUserCampusKey(currentUser);
if (!currentCampusKey) { if (!currentCampusKey) {
@ -222,7 +296,7 @@ class CampusAttendanceService {
const result = await db.campus_attendance_config.findAndCountAll({ const result = await db.campus_attendance_config.findAndCountAll({
where: { where: {
organizationId: requireOrganizationId(currentUser), organizationId: requireOrganizationId(currentUser),
...campusScope(filter, currentUser), ...(await campusScope(filter, currentUser)),
}, },
order: [['campus_key', 'asc']], order: [['campus_key', 'asc']],
limit, limit,
@ -243,7 +317,7 @@ class CampusAttendanceService {
assertCanManageCampusAttendance(currentUser); assertCanManageCampusAttendance(currentUser);
const campusKey = campusKeyFromRoute(campusKeyParam); const campusKey = campusKeyFromRoute(campusKeyParam);
assertCanAccessCampusKey(campusKey, currentUser); await assertCanAccessCampusKey(campusKey, currentUser);
if (!data || typeof data !== 'object' || Array.isArray(data)) { if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new ValidationError(); throw new ValidationError();
@ -285,7 +359,7 @@ class CampusAttendanceService {
const result = await db.campus_attendance_summaries.findAndCountAll({ const result = await db.campus_attendance_summaries.findAndCountAll({
where: { where: {
organizationId: requireOrganizationId(currentUser), organizationId: requireOrganizationId(currentUser),
...campusScope(filter, currentUser), ...(await campusScope(filter, currentUser)),
...dateRange(filter), ...dateRange(filter),
}, },
limit: clampLimit(filter.limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT), limit: clampLimit(filter.limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT),
@ -311,7 +385,7 @@ class CampusAttendanceService {
const campusKey = campusKeyFromRoute(campusKeyParam); const campusKey = campusKeyFromRoute(campusKeyParam);
const attendanceDate = requiredIsoDate(dateParam); const attendanceDate = requiredIsoDate(dateParam);
assertCanAccessCampusKey(campusKey, currentUser); await assertCanAccessCampusKey(campusKey, currentUser);
const validatedSummary = validateSummary(data); const validatedSummary = validateSummary(data);
const payload = { const payload = {

View File

@ -6,7 +6,7 @@ import ForbiddenError from '@/shared/errors/forbidden';
import ValidationError from '@/shared/errors/validation'; import ValidationError from '@/shared/errors/validation';
import { import {
assertAuthenticatedTenantUser, assertAuthenticatedTenantUser,
campusScope, campusDimensionScope,
getCampusId, getCampusId,
getOrganizationId, getOrganizationId,
getOrganizationIdOrGlobal, getOrganizationIdOrGlobal,
@ -23,7 +23,6 @@ import {
COMMUNICATION_MANAGER_ROLE_NAMES, COMMUNICATION_MANAGER_ROLE_NAMES,
COMMUNICATION_RECIPIENT_TYPES, COMMUNICATION_RECIPIENT_TYPES,
COMMUNICATION_STATUSES, COMMUNICATION_STATUSES,
COMMUNICATION_TENANT_WIDE_ROLE_NAMES,
DEFAULT_PARENT_MESSAGE_CATEGORY, DEFAULT_PARENT_MESSAGE_CATEGORY,
PARENT_MESSAGE_CATEGORY_VALUES, PARENT_MESSAGE_CATEGORY_VALUES,
type ParentMessageCategory, type ParentMessageCategory,
@ -165,7 +164,7 @@ class CommunicationsService {
...orgFilter, ...orgFilter,
...createdByFilter, ...createdByFilter,
audience: COMMUNICATION_AUDIENCES.GUARDIANS, audience: COMMUNICATION_AUDIENCES.GUARDIANS,
...campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES), ...campusDimensionScope(currentUser),
...(filter.category ? { subject: filter.category } : {}), ...(filter.category ? { subject: filter.category } : {}),
}, },
include: [ include: [
@ -259,7 +258,7 @@ class CommunicationsService {
const result = await db.communication_events.findAndCountAll({ const result = await db.communication_events.findAndCountAll({
where: { where: {
...orgFilter, ...orgFilter,
...campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES), ...campusDimensionScope(currentUser),
...(filter.type ? { event_type: filter.type } : {}), ...(filter.type ? { event_type: filter.type } : {}),
}, },
order: [ order: [

View File

@ -1,3 +1,4 @@
import { Op } from 'sequelize';
import db from '@/db/models'; import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction'; import { withTransaction } from '@/db/with-transaction';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
@ -6,6 +7,7 @@ import {
getOrganizationIdOrGlobal, getOrganizationIdOrGlobal,
getCampusId, getCampusId,
assertAuthenticatedTenantUser, assertAuthenticatedTenantUser,
campusDimensionScope,
hasRoleAccess, hasRoleAccess,
} from '@/services/shared/access'; } from '@/services/shared/access';
import { PERSONALITY_REPORT_ROLE_NAMES } from '@/shared/constants/personality'; import { PERSONALITY_REPORT_ROLE_NAMES } from '@/shared/constants/personality';
@ -152,7 +154,12 @@ class PersonalityQuizResultsService {
], ],
where: { where: {
...orgFilter, ...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'], group: ['personality_type'],
order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']], order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']],

View File

@ -6,6 +6,7 @@ import {
getOrganizationIdOrGlobal, getOrganizationIdOrGlobal,
getCampusId, getCampusId,
assertAuthenticatedTenantUser, assertAuthenticatedTenantUser,
campusDimensionScope,
hasRoleAccess, hasRoleAccess,
getDisplayName, getDisplayName,
} from '@/services/shared/access'; } from '@/services/shared/access';
@ -90,7 +91,7 @@ class SafetyQuizResultsService {
where: { where: {
...orgFilter, ...orgFilter,
...(hasRoleAccess(currentUser, SAFETY_QUIZ_REPORT_ROLE_NAMES) ...(hasRoleAccess(currentUser, SAFETY_QUIZ_REPORT_ROLE_NAMES)
? {} ? campusDimensionScope(currentUser)
: { userId: currentUser?.id ?? null }), : { userId: currentUser?.id ?? null }),
...(filter.week_of ? { week_of: filter.week_of } : {}), ...(filter.week_of ? { week_of: filter.week_of } : {}),
}, },

View File

@ -1,6 +1,12 @@
import { Op, literal } from 'sequelize';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
import { ROLE_SCOPES } from '@/shared/constants/roles';
import type { CurrentUser } from '@/db/api/types'; 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 * Shared tenant/role access helpers used by the feature services. Centralizes
* the (previously copy-pasted) organization/campus/role checks so every module * 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; 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( export function getRoleName(
currentUser?: CurrentUser, currentUser?: CurrentUser,
): string | null | undefined { ): string | null | undefined {
@ -108,3 +129,57 @@ export function campusScope(
const campusId = getCampusId(currentUser); const campusId = getCampusId(currentUser);
return campusId ? { campusId } : {}; 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 {};
}

View File

@ -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: * The roles each actor may create, re-assign, or delete on another user:
* - `super_admin`: anyone. * - `super_admin`: anyone.
* - `system_admin`: anyone except `super_admin` / `system_admin`. * - `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). * - `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. * - everyone else: nobody.
*/ */
const MANAGEABLE_ROLES_BY_ACTOR: Record<RoleName, readonly RoleName[]> = { const MANAGEABLE_ROLES_BY_ACTOR: Record<RoleName, readonly RoleName[]> = {
[ROLE_NAMES.SUPER_ADMIN]: ALL_ROLE_NAMES, [ROLE_NAMES.SUPER_ADMIN]: ALL_ROLE_NAMES,
[ROLE_NAMES.SYSTEM_ADMIN]: [ [ROLE_NAMES.SYSTEM_ADMIN]: [
ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER,
ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,
],
[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.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT,
ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST, 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.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER,
ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN,
ROLE_NAMES.GUEST, 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.DIRECTOR]: [
ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF,
ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST,

View File

@ -6,12 +6,11 @@ import {
STAFF_ATTENDANCE_MAX_LIMIT, STAFF_ATTENDANCE_MAX_LIMIT,
STAFF_ATTENDANCE_REPORT_ROLE_NAMES, STAFF_ATTENDANCE_REPORT_ROLE_NAMES,
STAFF_ATTENDANCE_STATUSES, STAFF_ATTENDANCE_STATUSES,
STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
} from '@/shared/constants/staff-attendance'; } from '@/shared/constants/staff-attendance';
import { STAFF_STATUSES } from '@/shared/constants/staff'; import { STAFF_STATUSES } from '@/shared/constants/staff';
import { import {
assertAuthenticatedTenantUser, assertAuthenticatedTenantUser,
campusScope, campusDimensionScope,
hasRoleAccess, hasRoleAccess,
requireOrganizationId, requireOrganizationId,
requireUserId, requireUserId,
@ -25,15 +24,17 @@ interface StaffAttendanceFilter {
limit?: unknown; limit?: unknown;
} }
/** Restricts records to the staff member, their campus, or the whole tenant. */ /**
function visibilityScope( * Restricts records to the staff member, or (for report roles) to the records
currentUser?: CurrentUser, * their scope allows: org-wide for owner/superintendent, the school's campuses
): { userId?: string; campusId?: string } { * for principal/registrar, a single campus for director.
*/
function visibilityScope(currentUser?: CurrentUser) {
if (!hasRoleAccess(currentUser, STAFF_ATTENDANCE_REPORT_ROLE_NAMES)) { if (!hasRoleAccess(currentUser, STAFF_ATTENDANCE_REPORT_ROLE_NAMES)) {
return { userId: requireUserId(currentUser) }; return { userId: requireUserId(currentUser) };
} }
return campusScope(currentUser, STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES); return campusDimensionScope(currentUser);
} }
function dateFilter(filter: StaffAttendanceFilter): { function dateFilter(filter: StaffAttendanceFilter): {
@ -127,7 +128,7 @@ class StaffAttendanceService {
where: { where: {
organizationId: requireOrganizationId(currentUser), organizationId: requireOrganizationId(currentUser),
status: STAFF_STATUSES.ACTIVE, status: STAFF_STATUSES.ACTIVE,
...campusScope(currentUser, STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES), ...campusDimensionScope(currentUser),
}, },
}), }),
]); ]);

View File

@ -4,11 +4,9 @@ import { withTransaction } from '@/db/with-transaction';
import { resolvePagination } from '@/shared/constants/pagination'; import { resolvePagination } from '@/shared/constants/pagination';
import ForbiddenError from '@/shared/errors/forbidden'; import ForbiddenError from '@/shared/errors/forbidden';
import ValidationError from '@/shared/errors/validation'; import ValidationError from '@/shared/errors/validation';
import { WALKTHROUGH_MANAGER_ROLE_NAMES } from '@/shared/constants/walkthrough';
import { import {
WALKTHROUGH_MANAGER_ROLE_NAMES, campusDimensionScope,
WALKTHROUGH_TENANT_WIDE_ROLE_NAMES,
} from '@/shared/constants/walkthrough';
import {
getOrganizationIdOrGlobal, getOrganizationIdOrGlobal,
hasGlobalAccess, hasGlobalAccess,
hasRoleAccess, 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 { class WalkthroughCheckinsService {
static async list(filter: WalkthroughFilter, currentUser?: CurrentUser) { static async list(filter: WalkthroughFilter, currentUser?: CurrentUser) {
assertCanManage(currentUser); assertCanManage(currentUser);
@ -142,7 +129,7 @@ class WalkthroughCheckinsService {
const result = await db.walkthrough_checkins.findAndCountAll({ const result = await db.walkthrough_checkins.findAndCountAll({
where: { where: {
...orgFilter, ...orgFilter,
...campusScope(currentUser), ...campusDimensionScope(currentUser),
...(filter.teacher_name ? { teacher_name: filter.teacher_name } : {}), ...(filter.teacher_name ? { teacher_name: filter.teacher_name } : {}),
}, },
order: [ order: [
@ -212,7 +199,7 @@ class WalkthroughCheckinsService {
where: { where: {
id, id,
...orgFilter, ...orgFilter,
...campusScope(currentUser), ...campusDimensionScope(currentUser),
}, },
}); });

View File

@ -1,14 +1,11 @@
import { ROLE_NAMES } from './roles'; 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.SUPER_ADMIN,
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
]);
export const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([
...CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR, ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.OFFICE_MANAGER,
]); ]);

View File

@ -49,12 +49,6 @@ export const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR, 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,
]);

View File

@ -5,5 +5,6 @@ export const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR, ROLE_NAMES.DIRECTOR,
]); ]);

View File

@ -5,5 +5,6 @@ export const FRAME_EDITOR_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR, ROLE_NAMES.DIRECTOR,
]); ]);

View File

@ -5,5 +5,7 @@ export const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.REGISTRAR,
ROLE_NAMES.DIRECTOR, ROLE_NAMES.DIRECTOR,
]); ]);

View File

@ -1,11 +1,14 @@
/** /**
* Authorization scope of a role (Workstream 3 §3.1). Determines the breadth of * 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 * a role's reach: platform-wide, a single organization, a single school (all its
* external-user surface, or the unauthenticated guest. Stored on `roles.scope`. * 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({ export const ROLE_SCOPES = Object.freeze({
SYSTEM: 'system', SYSTEM: 'system',
ORGANIZATION: 'organization', ORGANIZATION: 'organization',
SCHOOL: 'school',
CAMPUS: 'campus', CAMPUS: 'campus',
EXTERNAL: 'external', EXTERNAL: 'external',
GUEST: 'guest', 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` * uses these stable machine values; `roles.scope` is the matching scope. `guest`
* is the unauthenticated fallback (the seeded `Public` row), not an assignable * is the unauthenticated fallback (the seeded `Public` row), not an assignable
* user role. `globalAccess` (system roles only) bypasses tenant filtering. * 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({ export const ROLE_NAMES = Object.freeze({
SUPER_ADMIN: 'super_admin', SUPER_ADMIN: 'super_admin',
SYSTEM_ADMIN: 'system_admin', SYSTEM_ADMIN: 'system_admin',
OWNER: 'owner', OWNER: 'owner',
SUPERINTENDENT: 'superintendent', SUPERINTENDENT: 'superintendent',
PRINCIPAL: 'principal',
REGISTRAR: 'registrar',
DIRECTOR: 'director', DIRECTOR: 'director',
OFFICE_MANAGER: 'office_manager', OFFICE_MANAGER: 'office_manager',
TEACHER: 'teacher', 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.SYSTEM_ADMIN, scope: ROLE_SCOPES.SYSTEM, globalAccess: true },
{ name: ROLE_NAMES.OWNER, scope: ROLE_SCOPES.ORGANIZATION, globalAccess: false }, { name: ROLE_NAMES.OWNER, scope: ROLE_SCOPES.ORGANIZATION, globalAccess: false },
{ name: ROLE_NAMES.SUPERINTENDENT, 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.DIRECTOR, scope: ROLE_SCOPES.CAMPUS, globalAccess: false },
{ name: ROLE_NAMES.OFFICE_MANAGER, 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 }, { name: ROLE_NAMES.TEACHER, scope: ROLE_SCOPES.CAMPUS, globalAccess: false },

View File

@ -5,5 +5,7 @@ export const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.REGISTRAR,
ROLE_NAMES.DIRECTOR, ROLE_NAMES.DIRECTOR,
]); ]);

View File

@ -6,19 +6,37 @@ import {
} from '@/shared/constants/users'; } from '@/shared/constants/users';
/** /**
* RBAC seed fixtures (Workstream 4): one company, one campus (the `tigers` * RBAC seed fixtures (Workstream 4): one company, two schools, the six product
* product campus), staff covering every campus role, and exactly one loginable * campuses, staff covering every campus role, and exactly one loginable user per
* user per stored role. Single source shared by the admin-user seeder (creates * stored role. Single source shared by the admin-user seeder (creates the
* the users), the user-roles seeder (assigns roles), and the rbac-fixtures * users), the user-roles seeder (assigns roles), and the rbac-fixtures seeder
* seeder (org/campus/staff links). Pre-launch reset the DB and reseed. * (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_ID = 'b1a7c0de-0000-4000-8000-000000000001';
export const SEED_ORGANIZATION_NAME = 'Demo Academy'; 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). */ /** The campus the fixture staff are assigned to (the seeded `tigers` campus). */
export const SEED_CAMPUS_ID = PRODUCT_CAMPUS_SEED_ROWS[0].id; 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 type StaffType = 'teacher' | 'admin' | 'support';
export interface SeedFixtureUser { export interface SeedFixtureUser {
@ -31,8 +49,10 @@ export interface SeedFixtureUser {
readonly role: RoleName; readonly role: RoleName;
/** Uses `SEED_ADMIN_PASSWORD` (system roles) vs `SEED_USER_PASSWORD`. */ /** Uses `SEED_ADMIN_PASSWORD` (system roles) vs `SEED_USER_PASSWORD`. */
readonly admin: boolean; 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; readonly organization: boolean;
/** Gets `schoolId` = `SEED_SCHOOL_ID` (school + campus + external roles in School 1). */
readonly school: boolean;
/** Gets `campusId` (campus + external roles). */ /** Gets `campusId` (campus + external roles). */
readonly campus: boolean; readonly campus: boolean;
/** When set, a staff profile is created with this `staff_type`. */ /** 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[] = [ 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-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, 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, 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, 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-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-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-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-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-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-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-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-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-000000000018', email: 'student@flatlogic.com', firstName: 'Emma', lastName: 'Clark', role: ROLE_NAMES.STUDENT, admin: false, organization: true, campus: true }, { 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-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-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, role: ROLE_NAMES.OWNER,
admin: false, admin: false,
organization: true, organization: true,
school: false,
campus: false, campus: false,
}; };

View File

@ -11,14 +11,10 @@ export const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.REGISTRAR,
ROLE_NAMES.DIRECTOR, 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_DEFAULT_LIMIT = 90;
export const STAFF_ATTENDANCE_MAX_LIMIT = 366; export const STAFF_ATTENDANCE_MAX_LIMIT = 366;

View File

@ -5,12 +5,6 @@ export const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([
ROLE_NAMES.SYSTEM_ADMIN, ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER, ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR, 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,
]);

View File

@ -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`. 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 ## Button Usage
Use `Button` for new clickable commands. Prefer `leadingIcon`, `loading`, and `loadingLabel` instead of duplicating inline spinner branches. 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 ## Rules
- Keep UI variants and class maps in dedicated non-component files. - 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 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 `NativeSelect` for simple select fields where native browser behavior is sufficient.
- Use `ModuleHeader` for repeated page/module headings instead of duplicating title/icon markup. - Use `ModuleHeader` for repeated page/module headings instead of duplicating title/icon markup.

View File

@ -65,6 +65,10 @@ export function getAuthRoleLabel(role: UserRole): string {
return 'Owner'; return 'Owner';
case 'superintendent': case 'superintendent':
return 'Superintendent'; return 'Superintendent';
case 'principal':
return 'Principal';
case 'registrar':
return 'Registrar';
case 'director': case 'director':
return 'Director'; return 'Director';
case 'office_manager': case 'office_manager':

View File

@ -3,6 +3,7 @@ import { CommunityFilters } from '@/components/community-service/CommunityFilter
import { CommunityResults } from '@/components/community-service/CommunityResults'; import { CommunityResults } from '@/components/community-service/CommunityResults';
import { CommunityServiceHeader } from '@/components/community-service/CommunityServiceHeader'; import { CommunityServiceHeader } from '@/components/community-service/CommunityServiceHeader';
import { CommunityStatsGrid } from '@/components/community-service/CommunityStatsGrid'; import { CommunityStatsGrid } from '@/components/community-service/CommunityStatsGrid';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
interface CommunityServiceViewProps { interface CommunityServiceViewProps {
@ -10,24 +11,23 @@ interface CommunityServiceViewProps {
} }
export function CommunityServiceView({ workflow }: CommunityServiceViewProps) { export function CommunityServiceView({ workflow }: CommunityServiceViewProps) {
if (workflow.isLoading) {
return <PageSkeleton />;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<CommunityServiceHeader /> <CommunityServiceHeader />
{workflow.isLoading && (
<StatePanel tone="slate" loading>
Loading community organizations...
</StatePanel>
)}
{workflow.error && ( {workflow.error && (
<StatePanel tone="red" role="alert"> <StatePanel tone="red" role="alert">
Community organizations could not be loaded from the backend. Community organizations could not be loaded from the backend.
</StatePanel> </StatePanel>
)} )}
{!workflow.isLoading && !workflow.error && ( {!workflow.error && (
<> <>
<CommunityFilters workflow={workflow} /> <CommunityFilters workflow={workflow} />
<CommunityStatsGrid workflow={workflow} /> <CommunityStatsGrid workflow={workflow} />
<CommunityResults workflow={workflow} /> <CommunityResults workflow={workflow} />
</> </>
)} )}
</div> </div>

View File

@ -6,6 +6,7 @@ import { DirectorQuickActions } from '@/components/director-dashboard/DirectorQu
import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel'; import { DirectorQuizResultsPanel } from '@/components/director-dashboard/DirectorQuizResultsPanel';
import { DirectorRecentFramePanel } from '@/components/director-dashboard/DirectorRecentFramePanel'; import { DirectorRecentFramePanel } from '@/components/director-dashboard/DirectorRecentFramePanel';
import { DirectorRiskList } from '@/components/director-dashboard/DirectorRiskList'; import { DirectorRiskList } from '@/components/director-dashboard/DirectorRiskList';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
@ -21,11 +22,7 @@ export function DirectorDashboardView({
const errorMessage = getOptionalErrorMessage(page.error); const errorMessage = getOptionalErrorMessage(page.error);
if (page.isLoading) { if (page.isLoading) {
return ( return <PageSkeleton />;
<StatePanel tone="violet" alignment="center" loading className="my-20">
Loading director dashboard...
</StatePanel>
);
} }
if (errorMessage) { if (errorMessage) {

View File

@ -2,16 +2,6 @@ import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
import type { FrameModuleViewProps } from '@/components/frame/types'; 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) { export function FrameStatusPanel({ workflow }: FrameModuleViewProps) {
const errorMessage = getOptionalErrorMessage(workflow.error); const errorMessage = getOptionalErrorMessage(workflow.error);

View File

@ -6,6 +6,7 @@ import { TimerDisplay } from '@/components/classroom-timer/TimerDisplay';
import { TimerProjectionView } from '@/components/classroom-timer/TimerProjectionView'; import { TimerProjectionView } from '@/components/classroom-timer/TimerProjectionView';
import { TimerSettingsPanel } from '@/components/classroom-timer/TimerSettingsPanel'; import { TimerSettingsPanel } from '@/components/classroom-timer/TimerSettingsPanel';
import { TimerTips } from '@/components/classroom-timer/TimerTips'; import { TimerTips } from '@/components/classroom-timer/TimerTips';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
import type { UserRole } from '@/shared/types/app'; import type { UserRole } from '@/shared/types/app';
@ -19,11 +20,7 @@ const ClassroomTimer = ({ userRole }: ClassroomTimerProps) => {
const contentErrorMessage = getOptionalErrorMessage(timer.state.contentError); const contentErrorMessage = getOptionalErrorMessage(timer.state.contentError);
if (timer.state.isContentLoading) { if (timer.state.isContentLoading) {
return ( return <PageSkeleton />;
<StatePanel tone="cyan" loading>
Loading classroom timer content...
</StatePanel>
);
} }
if (contentErrorMessage || !timer.state.selectedBackground || !timer.state.selectedSound) { if (contentErrorMessage || !timer.state.selectedBackground || !timer.state.selectedSound) {

View File

@ -1,6 +1,6 @@
import { useFrameModule } from '@/business/frame/hooks'; import { useFrameModule } from '@/business/frame/hooks';
import { FrameModuleView } from '@/components/frame/FrameModuleView'; 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'; import type { UserRole } from '@/shared/types/app';
interface FrameModuleProps { interface FrameModuleProps {
@ -12,7 +12,7 @@ const FrameModule = ({ userRole, userName }: FrameModuleProps) => {
const workflow = useFrameModule(userRole, userName); const workflow = useFrameModule(userRole, userName);
if (workflow.isLoading) { if (workflow.isLoading) {
return <FrameLoadingPanel />; return <PageSkeleton />;
} }
return <FrameModuleView workflow={workflow} />; return <FrameModuleView workflow={workflow} />;

View File

@ -2,6 +2,7 @@ import type { PersonalityQuizWorkflow } from '@/components/personality-quiz/type
import { PersonalityQuizIntro } from '@/components/personality-quiz/PersonalityQuizIntro'; import { PersonalityQuizIntro } from '@/components/personality-quiz/PersonalityQuizIntro';
import { PersonalityQuizQuestionView } from '@/components/personality-quiz/PersonalityQuizQuestionView'; import { PersonalityQuizQuestionView } from '@/components/personality-quiz/PersonalityQuizQuestionView';
import { PersonalityResultView } from '@/components/personality-quiz/PersonalityResultView'; import { PersonalityResultView } from '@/components/personality-quiz/PersonalityResultView';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
interface PersonalityQuizViewProps { interface PersonalityQuizViewProps {
@ -18,11 +19,7 @@ export function PersonalityQuizView({
isSaving, isSaving,
}: PersonalityQuizViewProps) { }: PersonalityQuizViewProps) {
if (workflow.isContentLoading) { if (workflow.isContentLoading) {
return ( return <PageSkeleton />;
<StatePanel tone="violet" size="lg" alignment="center" loading>
Loading personality assessment...
</StatePanel>
);
} }
if (workflow.contentError) { if (workflow.contentError) {

View File

@ -2,6 +2,7 @@ import { Shield } from 'lucide-react';
import type { SafetyQuizPage } from '@/business/safety-quiz/types'; import type { SafetyQuizPage } from '@/business/safety-quiz/types';
import { ModuleHeader } from '@/components/ui/module-header'; import { ModuleHeader } from '@/components/ui/module-header';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
import { SafetyQuizCompliancePanel } from '@/components/safety-quiz/SafetyQuizCompliancePanel'; import { SafetyQuizCompliancePanel } from '@/components/safety-quiz/SafetyQuizCompliancePanel';
@ -17,6 +18,12 @@ interface SafetyQuizViewProps {
} }
export function SafetyQuizView({ page }: 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); const quizConfigurationError = getSafetyQuizConfigurationError(page.quiz);
return ( return (
@ -77,14 +84,6 @@ function SafetyQuizMainPanel({ page }: SafetyQuizMainPanelProps) {
const quizConfigurationError = getSafetyQuizConfigurationError(quiz); const quizConfigurationError = getSafetyQuizConfigurationError(quiz);
const question = quiz?.questions[page.currentQuestionIndex] ?? null; 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) { if (quizErrorMessage) {
return ( return (
<StatePanel tone="red" role="alert"> <StatePanel tone="red" role="alert">

View File

@ -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: '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: '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: '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: '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 /> }, { value: 'superintendent', label: 'Superintendent', desc: 'District-wide oversight', color: 'border-rose-500/30 bg-rose-500/10 text-rose-400', icon: <DistrictIcon /> },
]; ];

View File

@ -4,6 +4,7 @@ import { VocationalLoadingState } from '@/components/vocational-opportunities/Vo
import { VocationalPreSearchState } from '@/components/vocational-opportunities/VocationalPreSearchState'; import { VocationalPreSearchState } from '@/components/vocational-opportunities/VocationalPreSearchState';
import { VocationalResults } from '@/components/vocational-opportunities/VocationalResults'; import { VocationalResults } from '@/components/vocational-opportunities/VocationalResults';
import { VocationalZipSearch } from '@/components/vocational-opportunities/VocationalZipSearch'; import { VocationalZipSearch } from '@/components/vocational-opportunities/VocationalZipSearch';
import { PageSkeleton } from '@/components/ui/page-skeleton';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
interface VocationalOpportunitiesViewProps { interface VocationalOpportunitiesViewProps {
@ -11,20 +12,19 @@ interface VocationalOpportunitiesViewProps {
} }
export function VocationalOpportunitiesView({ workflow }: VocationalOpportunitiesViewProps) { export function VocationalOpportunitiesView({ workflow }: VocationalOpportunitiesViewProps) {
if (workflow.isLoading) {
return <PageSkeleton />;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<VocationalHeader /> <VocationalHeader />
{workflow.isLoading && (
<StatePanel tone="sky" loading>
Loading vocational opportunities...
</StatePanel>
)}
{workflow.error && ( {workflow.error && (
<StatePanel tone="red" role="alert"> <StatePanel tone="red" role="alert">
Vocational opportunities could not be loaded from the backend. Vocational opportunities could not be loaded from the backend.
</StatePanel> </StatePanel>
)} )}
{!workflow.isLoading && !workflow.error && ( {!workflow.error && (
<> <>
<VocationalZipSearch workflow={workflow} /> <VocationalZipSearch workflow={workflow} />
<VocationalLoadingState workflow={workflow} /> <VocationalLoadingState workflow={workflow} />

View File

@ -21,8 +21,9 @@ describe('zoneCheckins API', () => {
it('fetches today zone check-in status', async () => { it('fetches today zone check-in status', async () => {
const todayCheckin: ZoneCheckinTodayDto = { const todayCheckin: ZoneCheckinTodayDto = {
date: '2026-06-12',
zone: 'green', zone: 'green',
checkedInAt: '2026-06-12T10:00:00Z', isCheckedInToday: true,
}; };
apiRequestMock.mockResolvedValueOnce(todayCheckin); apiRequestMock.mockResolvedValueOnce(todayCheckin);
@ -33,8 +34,9 @@ describe('zoneCheckins API', () => {
it('checks in to a zone', async () => { it('checks in to a zone', async () => {
const todayCheckin: ZoneCheckinTodayDto = { const todayCheckin: ZoneCheckinTodayDto = {
date: '2026-06-12',
zone: 'blue', zone: 'blue',
checkedInAt: '2026-06-12T10:30:00Z', isCheckedInToday: true,
}; };
apiRequestMock.mockResolvedValueOnce(todayCheckin); apiRequestMock.mockResolvedValueOnce(todayCheckin);
@ -48,8 +50,9 @@ describe('zoneCheckins API', () => {
it('clears today zone check-in', async () => { it('clears today zone check-in', async () => {
const clearedCheckin: ZoneCheckinTodayDto = { const clearedCheckin: ZoneCheckinTodayDto = {
date: '2026-06-12',
zone: null, zone: null,
checkedInAt: null, isCheckedInToday: false,
}; };
apiRequestMock.mockResolvedValueOnce(clearedCheckin); apiRequestMock.mockResolvedValueOnce(clearedCheckin);

View File

@ -9,8 +9,12 @@ const ADMIN: readonly UserRole[] = [
'owner', 'owner',
'superintendent', '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[] = [ const ALL_STAFF: readonly UserRole[] = [
...ADMIN, ...ADMIN,
...SCHOOL_LEADERSHIP,
'director', 'director',
'office_manager', 'office_manager',
'teacher', 'teacher',
@ -19,14 +23,24 @@ const ALL_STAFF: readonly UserRole[] = [
// Campus modules office_manager does not get (classroom/instructional tools). // Campus modules office_manager does not get (classroom/instructional tools).
const STAFF_NO_OFFICE: readonly UserRole[] = [ const STAFF_NO_OFFICE: readonly UserRole[] = [
...ADMIN, ...ADMIN,
...SCHOOL_LEADERSHIP,
'director', 'director',
'teacher', 'teacher',
'support_staff', 'support_staff',
]; ];
// Parent communication: teaching staff + admin (no support/office). // Parent communication: teaching staff + admin + school leadership (no support/office).
const PARENT_COMM: readonly UserRole[] = [...ADMIN, 'director', 'teacher']; const PARENT_COMM: readonly UserRole[] = [
// Director-only surfaces (dashboard, walkthrough). ...ADMIN,
const DIRECTOR_ONLY: readonly UserRole[] = [...ADMIN, 'director']; ...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. // External-facing pages: all staff plus students and guardians.
const EXTERNAL: readonly UserRole[] = [...ALL_STAFF, 'student', 'guardian']; const EXTERNAL: readonly UserRole[] = [...ALL_STAFF, 'student', 'guardian'];

View File

@ -9,6 +9,8 @@ export const USER_ROLE_VALUES: readonly UserRole[] = [
'system_admin', 'system_admin',
'owner', 'owner',
'superintendent', 'superintendent',
'principal',
'registrar',
'director', 'director',
'office_manager', 'office_manager',
'teacher', 'teacher',

View File

@ -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' }, 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' }, 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' }, 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' }, 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' }, 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' }, teacher: { color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/20' },

View File

@ -3,6 +3,8 @@ export type UserRole =
| 'system_admin' | 'system_admin'
| 'owner' | 'owner'
| 'superintendent' | 'superintendent'
| 'principal'
| 'registrar'
| 'director' | 'director'
| 'office_manager' | 'office_manager'
| 'teacher' | 'teacher'