fixed migrations

This commit is contained in:
Dmitri 2026-06-12 07:21:27 +02:00
parent caa7cf0d0f
commit 017cdfd3d5
9 changed files with 231 additions and 75 deletions

2
.gitignore vendored
View File

@ -10,3 +10,5 @@ node_modules/
*.env.*
!.env.example
!*.env.example
.claude
CLAUDE.md

View File

@ -20,6 +20,12 @@ npm run db:migrate # Run pending migrations
npm run db:seed # Run seeders
npm run db:reset # Drop all → migrate → seed (destroys data)
npm run start # migrate + seed + watch (dev startup)
# VM Database Reset (requires platform credentials)
# 1. npm ci (install deps)
# 2. Get credentials: pm2 env 1 | grep DB_PASS && cat ~/executor/.env | grep DB_
# 3. DB_HOST=... DB_NAME=... DB_USER=... DB_PASS=... npm run db:reset
# 4. pm2 restart backend-dev frontend-dev --update-env
```
### Frontend (from `frontend/`)

1
backend/.gitignore vendored
View File

@ -1,2 +1,3 @@
dist/
*.tsbuildinfo
.env

View File

@ -11,17 +11,43 @@ import { ROLE_SCOPE_VALUES } from '@/shared/constants/roles';
* against the (empty, freshly-migrated) `roles` table; seeders populate it.
* `campusId` is a plain UUID (no DB-level FK), matching how
* `users.organizationId` is modeled associations use `constraints: false`.
*
* This migration is idempotent: columns/enums are only added if missing.
*/
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;
}
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('roles', 'scope', {
type: DataTypes.ENUM(...ROLE_SCOPE_VALUES),
allowNull: false,
});
await queryInterface.addColumn('users', 'campusId', {
type: DataTypes.UUID,
allowNull: true,
});
// Create enum type if not exists, then add column 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'))) {
await queryInterface.addColumn('roles', 'scope', {
type: DataTypes.ENUM(...ROLE_SCOPE_VALUES),
allowNull: false,
});
}
if (!(await columnExists(queryInterface, 'users', 'campusId'))) {
await queryInterface.addColumn('users', 'campusId', {
type: DataTypes.UUID,
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {

View File

@ -12,65 +12,96 @@ import { POLICY_DOCUMENT_CATEGORY_VALUES } from '@/shared/constants/policy-docum
* staff member's acknowledgment of a specific document version. Unique on
* (userId, policyDocumentId, version). Plain UUID references (no DB-level FK),
* matching the rest of the schema (associations use `constraints: false`).
*
* This migration is idempotent: tables/enums/indexes 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_schema = 'public' AND table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
async function indexExists(
queryInterface: QueryInterface,
indexName: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM pg_indexes WHERE indexname = '${indexName}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('policy_documents', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: { type: DataTypes.TEXT, allowNull: false },
body: { type: DataTypes.TEXT, allowNull: true },
category: {
type: DataTypes.ENUM(...POLICY_DOCUMENT_CATEGORY_VALUES),
allowNull: false,
},
// Optional finer sub-category within a category (e.g. the Handbook &
// Policies page's Operations/Behavior/Safety/Communication/Legal tag).
tag: { type: DataTypes.STRING(255), allowNull: true },
// Display name of the staff member who created the entry (shown as "by …").
author: { type: DataTypes.STRING(255), allowNull: true },
// Author-filled structured content (safety protocols): ordered procedure
// steps and autism-specific considerations. Null for handbook policies.
steps: { type: DataTypes.JSONB, allowNull: true },
autism_considerations: { type: DataTypes.JSONB, allowNull: true },
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { 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 },
});
// Create enum type if not exists
await queryInterface.sequelize.query(`
DO 'BEGIN
CREATE TYPE "public"."enum_policy_documents_category" AS ENUM(${POLICY_DOCUMENT_CATEGORY_VALUES.map((v) => `''${v}''`).join(', ')});
EXCEPTION WHEN duplicate_object THEN null; END';
`);
await queryInterface.createTable('policy_acknowledgments', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
policyDocumentId: { type: DataTypes.UUID, allowNull: false },
version: { type: DataTypes.INTEGER, allowNull: false },
userId: { type: DataTypes.UUID, allowNull: false },
acknowledgedAt: { type: DataTypes.DATE, allowNull: false },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { 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 },
});
if (!(await tableExists(queryInterface, 'policy_documents'))) {
await queryInterface.createTable('policy_documents', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: { type: DataTypes.TEXT, allowNull: false },
body: { type: DataTypes.TEXT, allowNull: true },
category: {
type: DataTypes.ENUM(...POLICY_DOCUMENT_CATEGORY_VALUES),
allowNull: false,
},
tag: { type: DataTypes.STRING(255), allowNull: true },
author: { type: DataTypes.STRING(255), allowNull: true },
steps: { type: DataTypes.JSONB, allowNull: true },
autism_considerations: { type: DataTypes.JSONB, allowNull: true },
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { 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 },
});
}
await queryInterface.addIndex('policy_acknowledgments', {
fields: ['userId', 'policyDocumentId', 'version'],
unique: true,
name: 'policy_acknowledgments_user_document_version_unique',
});
if (!(await tableExists(queryInterface, 'policy_acknowledgments'))) {
await queryInterface.createTable('policy_acknowledgments', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
policyDocumentId: { type: DataTypes.UUID, allowNull: false },
version: { type: DataTypes.INTEGER, allowNull: false },
userId: { type: DataTypes.UUID, allowNull: false },
acknowledgedAt: { type: DataTypes.DATE, allowNull: false },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { 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 },
});
}
if (!(await indexExists(queryInterface, 'policy_acknowledgments_user_document_version_unique'))) {
await queryInterface.addIndex('policy_acknowledgments', {
fields: ['userId', 'policyDocumentId', 'version'],
unique: true,
name: 'policy_acknowledgments_user_document_version_unique',
});
}
},
down: async (queryInterface: QueryInterface) => {

View File

@ -13,9 +13,26 @@ import { DataTypes, type QueryInterface } from 'sequelize';
*
* The binary itself is stored via the existing JWT-authenticated file subsystem
* (`POST /api/file/upload/...`); `url` holds the returned reference.
*
* This migration is idempotent: table is 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_schema = 'public' AND table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (await tableExists(queryInterface, 'audio_files')) {
return;
}
await queryInterface.createTable('audio_files', {
id: {
type: DataTypes.UUID,

View File

@ -5,13 +5,36 @@ import { USER_NAME_PREFIX_VALUES } from '@/shared/constants/users';
* Add an honorific name prefix (title) to users `Mr.` / `Ms.` / `Dr.` etc.
* Lets the UI render "Dr. Williams" without the title being baked into the
* person's first name. Nullable; no production data (pre-launch reset).
*
* This migration is idempotent: column/enum are only added if missing.
*/
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;
}
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('users', 'name_prefix', {
type: DataTypes.ENUM(...USER_NAME_PREFIX_VALUES),
allowNull: true,
});
// Create enum type if not exists
await queryInterface.sequelize.query(`
DO 'BEGIN
CREATE TYPE "public"."enum_users_name_prefix" AS ENUM(${USER_NAME_PREFIX_VALUES.map((v) => `''${v}''`).join(', ')});
EXCEPTION WHEN duplicate_object THEN null; END';
`);
if (!(await columnExists(queryInterface, 'users', 'name_prefix'))) {
await queryInterface.addColumn('users', 'name_prefix', {
type: DataTypes.ENUM(...USER_NAME_PREFIX_VALUES),
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {

View File

@ -11,18 +11,46 @@ import { AUDIO_FILE_KINDS } from '@/shared/constants/audio-files';
* `recipe` rows are played purely via the Web Audio API in the browser; they
* never reference the file subsystem and so are exempt from the download
* ownership check.
*
* This migration is idempotent: columns/enums are only added if missing.
*/
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;
}
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('audio_files', 'kind', {
type: DataTypes.ENUM(...AUDIO_FILE_KINDS),
allowNull: false,
defaultValue: 'file',
});
await queryInterface.addColumn('audio_files', 'recipe', {
type: DataTypes.JSONB,
allowNull: true,
});
// Create enum type if not exists
await queryInterface.sequelize.query(`
DO 'BEGIN
CREATE TYPE "public"."enum_audio_files_kind" AS ENUM(${AUDIO_FILE_KINDS.map((v) => `''${v}''`).join(', ')});
EXCEPTION WHEN duplicate_object THEN null; END';
`);
if (!(await columnExists(queryInterface, 'audio_files', 'kind'))) {
await queryInterface.addColumn('audio_files', 'kind', {
type: DataTypes.ENUM(...AUDIO_FILE_KINDS),
allowNull: false,
defaultValue: 'file',
});
}
if (!(await columnExists(queryInterface, 'audio_files', 'recipe'))) {
await queryInterface.addColumn('audio_files', 'recipe', {
type: DataTypes.JSONB,
allowNull: true,
});
}
// Make url nullable (changeColumn is idempotent)
await queryInterface.changeColumn('audio_files', 'url', {
type: DataTypes.STRING(2083),
allowNull: true,

View File

@ -98,6 +98,28 @@ For a guaranteed clean state (recommended after major migrations —
cd ~/executor/workspace/backend && npm run db:reset # drop all tables → migrate → seed
```
> ⚠️ **On VM**: `npm run db:reset` uses local dev credentials by default, which won't work.
> You must install dependencies and pass the platform-injected DB credentials explicitly:
>
> ```bash
> # 1. Install dependencies first
> cd ~/executor/workspace/backend && npm ci
> cd ~/executor/workspace/frontend && npm ci
>
> # 2. Get DB credentials
> pm2 env 1 | grep DB_PASS # DB_PASS from PM2 environment
> cat ~/executor/.env | grep DB_ # DB_NAME, DB_USER, DB_HOST, DB_PORT
>
> # 3. Run reset with correct credentials
> cd ~/executor/workspace/backend && \
> DB_HOST=127.0.0.1 DB_PORT=5432 \
> DB_NAME=app_<id> DB_USER=app_<id> DB_PASS=<uuid> \
> npm run db:reset
>
> # 4. Restart PM2 (has platform credentials)
> pm2 restart backend-dev frontend-dev --update-env
> ```
Restart processes (or the executor does this after pull):
```bash