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.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: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
1
backend/.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user