fixed migration

This commit is contained in:
Dmitri 2026-06-12 11:13:33 +02:00
parent 799eba7306
commit 040300f3d1

View File

@ -1,5 +1,9 @@
import { DataTypes, type QueryInterface } from 'sequelize'; 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 * 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; * and a nullable `campusId` to `users` (campus scope for campus-bound roles;
* null for system/organization scopes, so legitimately optional). * null for system/organization scopes, so legitimately optional).
* *
* Pre-launch with no production data, the `scope` column is added NOT NULL * Handles existing data: adds column as nullable, backfills scope based on
* against the (empty, freshly-migrated) `roles` table; seeders populate it. * role name using ROLE_DEFINITIONS mapping, then makes it NOT NULL.
*
* `campusId` is a plain UUID (no DB-level FK), matching how * `campusId` is a plain UUID (no DB-level FK), matching how
* `users.organizationId` is modeled associations use `constraints: false`. * `users.organizationId` is modeled associations use `constraints: false`.
* *
@ -26,17 +31,54 @@ async function columnExists(
return (results as unknown[]).length > 0; return (results as unknown[]).length > 0;
} }
async function columnIsNullable(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
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 { export default {
up: async (queryInterface: QueryInterface) => { 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(` await queryInterface.sequelize.query(`
DO 'BEGIN DO 'BEGIN
CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `''${v}''`).join(', ')}); CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `''${v}''`).join(', ')});
EXCEPTION WHEN duplicate_object THEN null; END'; 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', { 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), type: DataTypes.ENUM(...ROLE_SCOPE_VALUES),
allowNull: false, allowNull: false,
}); });