diff --git a/.gitignore b/.gitignore index 5e7b191..604486d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ node_modules/ *.env.* !.env.example !*.env.example +.claude +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e50bbbc..be40938 100644 --- a/CLAUDE.md +++ b/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/`) diff --git a/backend/.gitignore b/backend/.gitignore index 4bf7b95..a244137 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ dist/ *.tsbuildinfo +.env \ No newline at end of file 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 fb5ac78..fcfa1bd 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 @@ -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 { + 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) => { diff --git a/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts b/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts index 3089a3c..ca4abc6 100644 --- a/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts +++ b/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts @@ -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 { + 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 { + 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) => { diff --git a/backend/src/db/migrations/20260611010000-audio-files.ts b/backend/src/db/migrations/20260611010000-audio-files.ts index 9969e08..77112b2 100644 --- a/backend/src/db/migrations/20260611010000-audio-files.ts +++ b/backend/src/db/migrations/20260611010000-audio-files.ts @@ -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 { + 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, diff --git a/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts b/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts index 3d47d1f..85b080a 100644 --- a/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts +++ b/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts @@ -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 { + 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) => { diff --git a/backend/src/db/migrations/20260611060000-audio-files-kinds.ts b/backend/src/db/migrations/20260611060000-audio-files-kinds.ts index 019a877..a4bcf94 100644 --- a/backend/src/db/migrations/20260611060000-audio-files-kinds.ts +++ b/backend/src/db/migrations/20260611060000-audio-files-kinds.ts @@ -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 { + 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, diff --git a/docs/deployment-vm.md b/docs/deployment-vm.md index 7d91e01..5ef38e6 100644 --- a/docs/deployment-vm.md +++ b/docs/deployment-vm.md @@ -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_ DB_USER=app_ DB_PASS= \ +> 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