fixed migrations
This commit is contained in:
parent
caa7cf0d0f
commit
017cdfd3d5
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,3 +10,5 @@ node_modules/
|
|||||||
*.env.*
|
*.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!*.env.example
|
!*.env.example
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
@ -20,6 +20,12 @@ npm run db:migrate # Run pending migrations
|
|||||||
npm run db:seed # Run seeders
|
npm run db:seed # Run seeders
|
||||||
npm run db:reset # Drop all → migrate → seed (destroys data)
|
npm run db:reset # Drop all → migrate → seed (destroys data)
|
||||||
npm run start # migrate + seed + watch (dev startup)
|
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/`)
|
### Frontend (from `frontend/`)
|
||||||
|
|||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
dist/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
@ -11,17 +11,43 @@ import { ROLE_SCOPE_VALUES } from '@/shared/constants/roles';
|
|||||||
* against the (empty, freshly-migrated) `roles` table; seeders populate it.
|
* against the (empty, freshly-migrated) `roles` table; seeders populate it.
|
||||||
* `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`.
|
||||||
|
*
|
||||||
|
* 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 {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
await queryInterface.addColumn('roles', 'scope', {
|
// Create enum type if not exists, then add column if not exists
|
||||||
type: DataTypes.ENUM(...ROLE_SCOPE_VALUES),
|
await queryInterface.sequelize.query(`
|
||||||
allowNull: false,
|
DO 'BEGIN
|
||||||
});
|
CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `''${v}''`).join(', ')});
|
||||||
await queryInterface.addColumn('users', 'campusId', {
|
EXCEPTION WHEN duplicate_object THEN null; END';
|
||||||
type: DataTypes.UUID,
|
`);
|
||||||
allowNull: true,
|
|
||||||
});
|
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) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
|||||||
@ -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
|
* staff member's acknowledgment of a specific document version. Unique on
|
||||||
* (userId, policyDocumentId, version). Plain UUID references (no DB-level FK),
|
* (userId, policyDocumentId, version). Plain UUID references (no DB-level FK),
|
||||||
* matching the rest of the schema (associations use `constraints: false`).
|
* 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 {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
await queryInterface.createTable('policy_documents', {
|
// Create enum type if not exists
|
||||||
id: {
|
await queryInterface.sequelize.query(`
|
||||||
type: DataTypes.UUID,
|
DO 'BEGIN
|
||||||
defaultValue: DataTypes.UUIDV4,
|
CREATE TYPE "public"."enum_policy_documents_category" AS ENUM(${POLICY_DOCUMENT_CATEGORY_VALUES.map((v) => `''${v}''`).join(', ')});
|
||||||
primaryKey: true,
|
EXCEPTION WHEN duplicate_object THEN null; END';
|
||||||
},
|
`);
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.createTable('policy_acknowledgments', {
|
if (!(await tableExists(queryInterface, 'policy_documents'))) {
|
||||||
id: {
|
await queryInterface.createTable('policy_documents', {
|
||||||
type: DataTypes.UUID,
|
id: {
|
||||||
defaultValue: DataTypes.UUIDV4,
|
type: DataTypes.UUID,
|
||||||
primaryKey: true,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
},
|
primaryKey: true,
|
||||||
policyDocumentId: { type: DataTypes.UUID, allowNull: false },
|
},
|
||||||
version: { type: DataTypes.INTEGER, allowNull: false },
|
title: { type: DataTypes.TEXT, allowNull: false },
|
||||||
userId: { type: DataTypes.UUID, allowNull: false },
|
body: { type: DataTypes.TEXT, allowNull: true },
|
||||||
acknowledgedAt: { type: DataTypes.DATE, allowNull: false },
|
category: {
|
||||||
organizationId: { type: DataTypes.UUID, allowNull: true },
|
type: DataTypes.ENUM(...POLICY_DOCUMENT_CATEGORY_VALUES),
|
||||||
campusId: { type: DataTypes.UUID, allowNull: true },
|
allowNull: false,
|
||||||
createdById: { type: DataTypes.UUID, allowNull: true },
|
},
|
||||||
updatedById: { type: DataTypes.UUID, allowNull: true },
|
tag: { type: DataTypes.STRING(255), allowNull: true },
|
||||||
createdAt: { type: DataTypes.DATE },
|
author: { type: DataTypes.STRING(255), allowNull: true },
|
||||||
updatedAt: { type: DataTypes.DATE },
|
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', {
|
if (!(await tableExists(queryInterface, 'policy_acknowledgments'))) {
|
||||||
fields: ['userId', 'policyDocumentId', 'version'],
|
await queryInterface.createTable('policy_acknowledgments', {
|
||||||
unique: true,
|
id: {
|
||||||
name: 'policy_acknowledgments_user_document_version_unique',
|
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) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
|||||||
@ -13,9 +13,26 @@ import { DataTypes, type QueryInterface } from 'sequelize';
|
|||||||
*
|
*
|
||||||
* The binary itself is stored via the existing JWT-authenticated file subsystem
|
* The binary itself is stored via the existing JWT-authenticated file subsystem
|
||||||
* (`POST /api/file/upload/...`); `url` holds the returned reference.
|
* (`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 {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
if (await tableExists(queryInterface, 'audio_files')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await queryInterface.createTable('audio_files', {
|
await queryInterface.createTable('audio_files', {
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
|
|||||||
@ -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.
|
* 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
|
* Lets the UI render "Dr. Williams" without the title being baked into the
|
||||||
* person's first name. Nullable; no production data (pre-launch reset).
|
* 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 {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
await queryInterface.addColumn('users', 'name_prefix', {
|
// Create enum type if not exists
|
||||||
type: DataTypes.ENUM(...USER_NAME_PREFIX_VALUES),
|
await queryInterface.sequelize.query(`
|
||||||
allowNull: true,
|
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) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
|||||||
@ -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
|
* `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
|
* never reference the file subsystem and so are exempt from the download
|
||||||
* ownership check.
|
* 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 {
|
export default {
|
||||||
up: async (queryInterface: QueryInterface) => {
|
up: async (queryInterface: QueryInterface) => {
|
||||||
await queryInterface.addColumn('audio_files', 'kind', {
|
// Create enum type if not exists
|
||||||
type: DataTypes.ENUM(...AUDIO_FILE_KINDS),
|
await queryInterface.sequelize.query(`
|
||||||
allowNull: false,
|
DO 'BEGIN
|
||||||
defaultValue: 'file',
|
CREATE TYPE "public"."enum_audio_files_kind" AS ENUM(${AUDIO_FILE_KINDS.map((v) => `''${v}''`).join(', ')});
|
||||||
});
|
EXCEPTION WHEN duplicate_object THEN null; END';
|
||||||
await queryInterface.addColumn('audio_files', 'recipe', {
|
`);
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
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', {
|
await queryInterface.changeColumn('audio_files', 'url', {
|
||||||
type: DataTypes.STRING(2083),
|
type: DataTypes.STRING(2083),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -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
|
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):
|
Restart processes (or the executor does this after pull):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user