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?: {
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;

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 { 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 },

View File

@ -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),

View File

@ -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',

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 { 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 },

View File

@ -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 },

View File

@ -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,

View File

@ -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] } },

View File

@ -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 = {

View File

@ -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: [

View File

@ -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']],

View File

@ -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 } : {}),
},

View File

@ -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 {};
}

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:
* - `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,

View File

@ -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),
},
}),
]);

View File

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

View File

@ -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,
]);

View File

@ -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,
]);

View File

@ -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,
]);

View File

@ -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,
]);

View File

@ -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,
]);

View File

@ -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 },

View File

@ -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,
]);

View File

@ -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,
};

View File

@ -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;

View File

@ -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,
]);

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`.
## 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.

View File

@ -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':

View File

@ -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>

View File

@ -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) {

View File

@ -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);

View File

@ -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) {

View File

@ -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} />;

View File

@ -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) {

View File

@ -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">

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: '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 /> },
];

View File

@ -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} />

View File

@ -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);

View File

@ -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'];

View File

@ -9,6 +9,8 @@ export const USER_ROLE_VALUES: readonly UserRole[] = [
'system_admin',
'owner',
'superintendent',
'principal',
'registrar',
'director',
'office_manager',
'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' },
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' },

View File

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