diff --git a/backend/src/db/api/types.ts b/backend/src/db/api/types.ts index e312e8d..e8e986a 100644 --- a/backend/src/db/api/types.ts +++ b/backend/src/db/api/types.ts @@ -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; } | 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; diff --git a/backend/src/db/migrations/20260612010000-add-schools-tier.ts b/backend/src/db/migrations/20260612010000-add-schools-tier.ts new file mode 100644 index 0000000..9e3f93d --- /dev/null +++ b/backend/src/db/migrations/20260612010000-add-schools-tier.ts @@ -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 { + 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 { + 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 { + 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'); + } + }, +}; diff --git a/backend/src/db/models/campuses.ts b/backend/src/db/models/campuses.ts index 0b84198..4a14662 100644 --- a/backend/src/db/models/campuses.ts +++ b/backend/src/db/models/campuses.ts @@ -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; declare importHash: CreationOptional; declare organizationId: CreationOptional; + /** School scope (Organization → School → Campus). Nullable until backfilled. */ + declare schoolId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; declare createdAt: CreationOptional; @@ -63,6 +66,8 @@ export class Campuses extends Model< declare setMessages_campus: HasManySetAssociationsMixin; declare getOrganization: BelongsToGetAssociationMixin; declare setOrganization: BelongsToSetAssociationMixin; + declare getSchool: BelongsToGetAssociationMixin; + declare setSchool: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -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 }, diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index e9c3a1b..2cd5b69 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -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), diff --git a/backend/src/db/models/organizations.ts b/backend/src/db/models/organizations.ts index 3abb837..749b437 100644 --- a/backend/src/db/models/organizations.ts +++ b/backend/src/db/models/organizations.ts @@ -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', diff --git a/backend/src/db/models/schools.ts b/backend/src/db/models/schools.ts new file mode 100644 index 0000000..2b604e5 --- /dev/null +++ b/backend/src/db/models/schools.ts @@ -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, + InferCreationAttributes +> { + declare id: CreationOptional; + 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; + declare importHash: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + declare getCampuses_school: HasManyGetAssociationsMixin; + declare setCampuses_school: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + 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; +} diff --git a/backend/src/db/models/staff.ts b/backend/src/db/models/staff.ts index 9bb3f19..6abfcbe 100644 --- a/backend/src/db/models/staff.ts +++ b/backend/src/db/models/staff.ts @@ -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; declare campusId: CreationOptional; declare organizationId: CreationOptional; + /** School scope (Organization → School → Campus). Nullable. */ + declare schoolId: CreationOptional; declare userId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; @@ -53,6 +56,8 @@ export class Staff extends Model< declare setOrganization: BelongsToSetAssociationMixin; declare getCampus: BelongsToGetAssociationMixin; declare setCampus: BelongsToSetAssociationMixin; + declare getSchool: BelongsToGetAssociationMixin; + declare setSchool: BelongsToSetAssociationMixin; // Eager-loaded association (populated by `include`). declare campus?: NonAttribute; declare getUser: BelongsToGetAssociationMixin; @@ -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 }, diff --git a/backend/src/db/models/users.ts b/backend/src/db/models/users.ts index 63eb5da..deb1c23 100644 --- a/backend/src/db/models/users.ts +++ b/backend/src/db/models/users.ts @@ -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; /** Campus scope for campus-bound roles (Workstream 3 §3.1). Nullable. */ declare campusId: CreationOptional; + /** School scope for school-bound roles (Principal/Registrar). Nullable. */ + declare schoolId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; declare createdAt: CreationOptional; @@ -82,6 +85,8 @@ export class Users extends Model< declare setOrganizations: BelongsToSetAssociationMixin; declare getCampus: BelongsToGetAssociationMixin; declare setCampus: BelongsToSetAssociationMixin; + declare getSchool: BelongsToGetAssociationMixin; + declare setSchool: BelongsToSetAssociationMixin; declare getAvatar: HasManyGetAssociationsMixin; declare setAvatar: HasManySetAssociationsMixin; declare getCreatedBy: BelongsToGetAssociationMixin; @@ -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 }, diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index e82e6a6..2f0b33a 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -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> = { + // 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, diff --git a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts index 8fbce3c..fe8f7da 100644 --- a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts +++ b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts @@ -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[] = [ + { 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] } }, diff --git a/backend/src/services/campus_attendance.ts b/backend/src/services/campus_attendance.ts index cdcb949..755ee1e 100644 --- a/backend/src/services/campus_attendance.ts +++ b/backend/src/services/campus_attendance.ts @@ -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 { + 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 { + 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 } }> { 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 = { diff --git a/backend/src/services/communications.ts b/backend/src/services/communications.ts index ce08b6c..b890aab 100644 --- a/backend/src/services/communications.ts +++ b/backend/src/services/communications.ts @@ -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: [ diff --git a/backend/src/services/personality_quiz_results.ts b/backend/src/services/personality_quiz_results.ts index 8b90b71..e633fef 100644 --- a/backend/src/services/personality_quiz_results.ts +++ b/backend/src/services/personality_quiz_results.ts @@ -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']], diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts index 6abdeff..120a907 100644 --- a/backend/src/services/safety_quiz_results.ts +++ b/backend/src/services/safety_quiz_results.ts @@ -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 } : {}), }, diff --git a/backend/src/services/shared/access.ts b/backend/src/services/shared/access.ts index 1af35b3..ffe120c 100644 --- a/backend/src/services/shared/access.ts +++ b/backend/src/services/shared/access.ts @@ -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 }; +}; + +/** + * 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 {}; +} diff --git a/backend/src/services/shared/role-policy.ts b/backend/src/services/shared/role-policy.ts index 5a53deb..86ed3a7 100644 --- a/backend/src/services/shared/role-policy.ts +++ b/backend/src/services/shared/role-policy.ts @@ -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 = { [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, diff --git a/backend/src/services/staff_attendance.ts b/backend/src/services/staff_attendance.ts index 60bbba7..e0a51b5 100644 --- a/backend/src/services/staff_attendance.ts +++ b/backend/src/services/staff_attendance.ts @@ -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), }, }), ]); diff --git a/backend/src/services/walkthrough_checkins.ts b/backend/src/services/walkthrough_checkins.ts index 85874b1..48a0267 100644 --- a/backend/src/services/walkthrough_checkins.ts +++ b/backend/src/services/walkthrough_checkins.ts @@ -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), }, }); diff --git a/backend/src/shared/constants/campus-attendance.ts b/backend/src/shared/constants/campus-attendance.ts index 8f549f7..a14bf61 100644 --- a/backend/src/shared/constants/campus-attendance.ts +++ b/backend/src/shared/constants/campus-attendance.ts @@ -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, ]); diff --git a/backend/src/shared/constants/communications.ts b/backend/src/shared/constants/communications.ts index fa46728..89454f9 100644 --- a/backend/src/shared/constants/communications.ts +++ b/backend/src/shared/constants/communications.ts @@ -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, -]); diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index d736c2b..a0d9ff9 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -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, ]); diff --git a/backend/src/shared/constants/frame.ts b/backend/src/shared/constants/frame.ts index ef9bbc4..e52695d 100644 --- a/backend/src/shared/constants/frame.ts +++ b/backend/src/shared/constants/frame.ts @@ -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, ]); diff --git a/backend/src/shared/constants/personality.ts b/backend/src/shared/constants/personality.ts index 51a22ac..7c72119 100644 --- a/backend/src/shared/constants/personality.ts +++ b/backend/src/shared/constants/personality.ts @@ -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, ]); diff --git a/backend/src/shared/constants/roles.ts b/backend/src/shared/constants/roles.ts index 2e2bfbc..d5a0adc 100644 --- a/backend/src/shared/constants/roles.ts +++ b/backend/src/shared/constants/roles.ts @@ -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 }, diff --git a/backend/src/shared/constants/safety-quiz.ts b/backend/src/shared/constants/safety-quiz.ts index 2223a94..fd98b49 100644 --- a/backend/src/shared/constants/safety-quiz.ts +++ b/backend/src/shared/constants/safety-quiz.ts @@ -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, ]); diff --git a/backend/src/shared/constants/seed-fixtures.ts b/backend/src/shared/constants/seed-fixtures.ts index 2034fb5..1abcd93 100644 --- a/backend/src/shared/constants/seed-fixtures.ts +++ b/backend/src/shared/constants/seed-fixtures.ts @@ -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> = + 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, }; diff --git a/backend/src/shared/constants/staff-attendance.ts b/backend/src/shared/constants/staff-attendance.ts index c9b3ee1..38c2ab7 100644 --- a/backend/src/shared/constants/staff-attendance.ts +++ b/backend/src/shared/constants/staff-attendance.ts @@ -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; diff --git a/backend/src/shared/constants/walkthrough.ts b/backend/src/shared/constants/walkthrough.ts index f041ddb..3ad8c42 100644 --- a/backend/src/shared/constants/walkthrough.ts +++ b/backend/src/shared/constants/walkthrough.ts @@ -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, -]); diff --git a/frontend/docs/ui-kit.md b/frontend/docs/ui-kit.md index db49d57..f2d03c3 100644 --- a/frontend/docs/ui-kit.md +++ b/frontend/docs/ui-kit.md @@ -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 ; + } + ``` + +- **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. diff --git a/frontend/src/business/auth/selectors.ts b/frontend/src/business/auth/selectors.ts index cf0bcdb..af80a5d 100644 --- a/frontend/src/business/auth/selectors.ts +++ b/frontend/src/business/auth/selectors.ts @@ -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': diff --git a/frontend/src/components/community-service/CommunityServiceView.tsx b/frontend/src/components/community-service/CommunityServiceView.tsx index cdad0d5..d0cbefc 100644 --- a/frontend/src/components/community-service/CommunityServiceView.tsx +++ b/frontend/src/components/community-service/CommunityServiceView.tsx @@ -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 ; + } + return (
- {workflow.isLoading && ( - - Loading community organizations... - - )} {workflow.error && ( Community organizations could not be loaded from the backend. )} - {!workflow.isLoading && !workflow.error && ( + {!workflow.error && ( <> - - - + + + )}
diff --git a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx index 2857d53..d87b344 100644 --- a/frontend/src/components/director-dashboard/DirectorDashboardView.tsx +++ b/frontend/src/components/director-dashboard/DirectorDashboardView.tsx @@ -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 ( - - Loading director dashboard... - - ); + return ; } if (errorMessage) { diff --git a/frontend/src/components/frame/FrameStatusPanel.tsx b/frontend/src/components/frame/FrameStatusPanel.tsx index bb0220f..86cf1c0 100644 --- a/frontend/src/components/frame/FrameStatusPanel.tsx +++ b/frontend/src/components/frame/FrameStatusPanel.tsx @@ -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 ( -
- - Loading F.R.A.M.E. entries... - -
- ); -} - export function FrameStatusPanel({ workflow }: FrameModuleViewProps) { const errorMessage = getOptionalErrorMessage(workflow.error); diff --git a/frontend/src/components/frameworks/ClassroomTimer.tsx b/frontend/src/components/frameworks/ClassroomTimer.tsx index 75a5065..ae9b9f3 100644 --- a/frontend/src/components/frameworks/ClassroomTimer.tsx +++ b/frontend/src/components/frameworks/ClassroomTimer.tsx @@ -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 ( - - Loading classroom timer content... - - ); + return ; } if (contentErrorMessage || !timer.state.selectedBackground || !timer.state.selectedSound) { diff --git a/frontend/src/components/frameworks/FrameModule.tsx b/frontend/src/components/frameworks/FrameModule.tsx index 073ae0a..2a24920 100644 --- a/frontend/src/components/frameworks/FrameModule.tsx +++ b/frontend/src/components/frameworks/FrameModule.tsx @@ -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 ; + return ; } return ; diff --git a/frontend/src/components/personality-quiz/PersonalityQuizView.tsx b/frontend/src/components/personality-quiz/PersonalityQuizView.tsx index b27a128..2383d27 100644 --- a/frontend/src/components/personality-quiz/PersonalityQuizView.tsx +++ b/frontend/src/components/personality-quiz/PersonalityQuizView.tsx @@ -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 ( - - Loading personality assessment... - - ); + return ; } if (workflow.contentError) { diff --git a/frontend/src/components/safety-quiz/SafetyQuizView.tsx b/frontend/src/components/safety-quiz/SafetyQuizView.tsx index 6e4e62d..e0bdd62 100644 --- a/frontend/src/components/safety-quiz/SafetyQuizView.tsx +++ b/frontend/src/components/safety-quiz/SafetyQuizView.tsx @@ -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 ; + } + 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 ( - - Loading safety quiz... - - ); - } - if (quizErrorMessage) { return ( diff --git a/frontend/src/components/sign-in-modal/SignupRoleStep.tsx b/frontend/src/components/sign-in-modal/SignupRoleStep.tsx index e14a5cc..8e3c812 100644 --- a/frontend/src/components/sign-in-modal/SignupRoleStep.tsx +++ b/frontend/src/components/sign-in-modal/SignupRoleStep.tsx @@ -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: }, { value: 'support_staff', label: 'Support Staff', desc: 'Paraprofessional', color: 'border-blue-500/30 bg-blue-500/10 text-blue-400', icon: }, { value: 'office_manager', label: 'Office Manager', desc: 'Administrative staff', color: 'border-amber-500/30 bg-amber-500/10 text-amber-400', icon: }, + { value: 'registrar', label: 'Registrar', desc: 'School records & audit', color: 'border-violet-500/30 bg-violet-500/10 text-violet-400', icon: }, { value: 'director', label: 'Director', desc: 'Campus leadership', color: 'border-purple-500/30 bg-purple-500/10 text-purple-400', icon: }, + { value: 'principal', label: 'Principal', desc: 'School leadership', color: 'border-fuchsia-500/30 bg-fuchsia-500/10 text-fuchsia-400', icon: }, { value: 'superintendent', label: 'Superintendent', desc: 'District-wide oversight', color: 'border-rose-500/30 bg-rose-500/10 text-rose-400', icon: }, ]; diff --git a/frontend/src/components/vocational-opportunities/VocationalOpportunitiesView.tsx b/frontend/src/components/vocational-opportunities/VocationalOpportunitiesView.tsx index 8f6b8b4..a41ca80 100644 --- a/frontend/src/components/vocational-opportunities/VocationalOpportunitiesView.tsx +++ b/frontend/src/components/vocational-opportunities/VocationalOpportunitiesView.tsx @@ -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 ; + } + return (
- {workflow.isLoading && ( - - Loading vocational opportunities... - - )} {workflow.error && ( Vocational opportunities could not be loaded from the backend. )} - {!workflow.isLoading && !workflow.error && ( + {!workflow.error && ( <> diff --git a/frontend/src/shared/api/zoneCheckins.test.ts b/frontend/src/shared/api/zoneCheckins.test.ts index 54b801c..a5bf991 100644 --- a/frontend/src/shared/api/zoneCheckins.test.ts +++ b/frontend/src/shared/api/zoneCheckins.test.ts @@ -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); diff --git a/frontend/src/shared/constants/appData.ts b/frontend/src/shared/constants/appData.ts index ee03584..079a03a 100644 --- a/frontend/src/shared/constants/appData.ts +++ b/frontend/src/shared/constants/appData.ts @@ -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']; diff --git a/frontend/src/shared/constants/roles.ts b/frontend/src/shared/constants/roles.ts index 893aed5..c9e678d 100644 --- a/frontend/src/shared/constants/roles.ts +++ b/frontend/src/shared/constants/roles.ts @@ -9,6 +9,8 @@ export const USER_ROLE_VALUES: readonly UserRole[] = [ 'system_admin', 'owner', 'superintendent', + 'principal', + 'registrar', 'director', 'office_manager', 'teacher', diff --git a/frontend/src/shared/constants/topBar.ts b/frontend/src/shared/constants/topBar.ts index f33e48f..f799aae 100644 --- a/frontend/src/shared/constants/topBar.ts +++ b/frontend/src/shared/constants/topBar.ts @@ -5,6 +5,8 @@ export const TOP_BAR_ROLE_BADGE_CLASSES: Record