diff --git a/migrations/1781000000003_reconcile-collaboration-tables.js b/migrations/1781000000003_reconcile-collaboration-tables.js new file mode 100644 index 0000000..1f9c5ab --- /dev/null +++ b/migrations/1781000000003_reconcile-collaboration-tables.js @@ -0,0 +1,101 @@ +/** + * Reconcile collaboration tables missed by the initial-schema backfill. + * + * Captures the DDL half of `scripts/add-collaboration-features.js` (a + * historical no-go-zone script) that the `initial-schema` backfill missed: + * + * 1. `collection_permissions` (collaboration roles + invite tokens) + * 2. `collection_activity` (audit-trail with JSONB details) + * 3. `users.is_pending` (column for invited-but-not-yet-accepted users) + * 4. 4 indexes for query hot-paths + * + * `lib/permission-middleware.js` reads `collection_permissions` in + * `withCollectionPermission` and writes `collection_activity` from + * `logCollectionActivity` — both are runtime invariants, so a fresh Neon + * branch onboarded by `npm run setup-db` MUST land these tables. Prod was + * brought to this shape by `add-collaboration-features.js` running + * historically; this migration brings fresh envs to parity per + * `.convoys/reconcile-historical-add-scripts.md` § Brief outline → B3. + * + * Idempotent against: + * - A brand-new Neon branch (creates everything fresh). + * - Any long-lived env where `add-collaboration-features.js` already ran + * (every CREATE TABLE / ADD COLUMN / CREATE INDEX is `IF NOT EXISTS`- + * guarded → no-op except recording the `pgmigrations` row). + * + * The owner-permission DML backfill from the historical script is NOT + * captured: a fresh env has no pre-existing collections needing + * back-fill, and prod's backfill is already applied. + * + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + * @returns {void} + */ +export const up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS collection_permissions ( + id SERIAL PRIMARY KEY, + collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'pending', 'declined')), + invite_token VARCHAR(255) UNIQUE, + invited_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + pgm.sql(` + CREATE TABLE IF NOT EXISTS collection_activity ( + id SERIAL PRIMARY KEY, + collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + details JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + pgm.sql(` + ALTER TABLE users + ADD COLUMN IF NOT EXISTS is_pending BOOLEAN DEFAULT false + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_collection_permissions_collection_id + ON collection_permissions(collection_id); + CREATE INDEX IF NOT EXISTS idx_collection_permissions_user_id + ON collection_permissions(user_id); + CREATE INDEX IF NOT EXISTS idx_collection_activity_collection_id + ON collection_activity(collection_id); + CREATE INDEX IF NOT EXISTS idx_collections_visibility + ON collections(visibility); + `); +}; + +/** + * Down-migration is a hard stub. Dropping `collection_permissions` / + * `collection_activity` would erase every collaboration role assignment + * and every audit-trail row in the database; dropping `users.is_pending` + * would discard the invited-user state for every pending invite. If a + * future schema correction needs to mutate one of these surfaces, write a + * NEW dated migration with a real `down` — do NOT remove this stub. + * + * @returns {void} + */ +export const down = () => { + throw new Error( + '[migration:1781000000003_reconcile-collaboration-tables] Refusing to drop ' + + 'collection_permissions / collection_activity / users.is_pending. Rolling back ' + + 'this migration would erase every collaboration role assignment, every audit-trail ' + + 'row, and every invited-user pending state in the database. If you need a clean ' + + 'schema for testing, branch the Neon database and run `npm run migrate up` against ' + + 'the branch instead.' + ); +};