diff --git a/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts b/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts index fcfa1bd..2c9ae07 100644 --- a/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts +++ b/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts @@ -1,5 +1,9 @@ import { DataTypes, type QueryInterface } from 'sequelize'; -import { ROLE_SCOPE_VALUES } from '@/shared/constants/roles'; +import { + ROLE_SCOPE_VALUES, + ROLE_DEFINITIONS, + ROLE_SCOPES, +} from '@/shared/constants/roles'; /** * Workstream 3 §3.1 foundation: add the authorization `scope` to `roles` (NOT @@ -7,8 +11,9 @@ import { ROLE_SCOPE_VALUES } from '@/shared/constants/roles'; * and a nullable `campusId` to `users` (campus scope for campus-bound roles; * null for system/organization scopes, so legitimately optional). * - * Pre-launch with no production data, the `scope` column is added NOT NULL - * against the (empty, freshly-migrated) `roles` table; seeders populate it. + * Handles existing data: adds column as nullable, backfills scope based on + * role name using ROLE_DEFINITIONS mapping, then makes it NOT NULL. + * * `campusId` is a plain UUID (no DB-level FK), matching how * `users.organizationId` is modeled — associations use `constraints: false`. * @@ -26,17 +31,54 @@ async function columnExists( return (results as unknown[]).length > 0; } +async function columnIsNullable( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT is_nullable FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + const row = (results as { is_nullable: string }[])[0]; + return row?.is_nullable === 'YES'; +} + export default { up: async (queryInterface: QueryInterface) => { - // Create enum type if not exists, then add column if not exists + // Create enum type if not exists await queryInterface.sequelize.query(` DO 'BEGIN CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `''${v}''`).join(', ')}); EXCEPTION WHEN duplicate_object THEN null; END'; `); - if (!(await columnExists(queryInterface, 'roles', 'scope'))) { + const scopeExists = await columnExists(queryInterface, 'roles', 'scope'); + + if (!scopeExists) { + // Step 1: Add column as nullable first await queryInterface.addColumn('roles', 'scope', { + type: DataTypes.ENUM(...ROLE_SCOPE_VALUES), + allowNull: true, + }); + } + + // Step 2: Backfill existing rows with correct scope based on role name + for (const def of ROLE_DEFINITIONS) { + await queryInterface.sequelize.query( + `UPDATE roles SET scope = :scope WHERE name = :name AND scope IS NULL`, + { replacements: { scope: def.scope, name: def.name } }, + ); + } + // Fallback for any roles not in ROLE_DEFINITIONS (e.g., custom roles) + await queryInterface.sequelize.query( + `UPDATE roles SET scope = :scope WHERE scope IS NULL`, + { replacements: { scope: ROLE_SCOPES.CAMPUS } }, + ); + + // Step 3: Make column NOT NULL if still nullable + if (!scopeExists || (await columnIsNullable(queryInterface, 'roles', 'scope'))) { + await queryInterface.changeColumn('roles', 'scope', { type: DataTypes.ENUM(...ROLE_SCOPE_VALUES), allowNull: false, });