feat(db): drop users.consentGiven/consentAt (ADR-0014 step 8)
Backfills consent_given=1 rows into user_consents as data:core before dropping the legacy columns. auth.ts now writes user_consents on signup; POST /consent writes user_consents; admin/user routes cleaned of the old fields. Migration is idempotent — DROP COLUMN is wrapped in try/catch so it no-ops on fresh DBs that never had the columns. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Migration tests — apply runMigrations() to a fresh in-memory SQLite handle
|
||||
* and verify schema, idempotency, and the consent_given → user_consents backfill.
|
||||
* and verify schema shape and idempotency.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -13,7 +13,7 @@ function freshDb() {
|
||||
}
|
||||
|
||||
describe('runMigrations — fresh DB', () => {
|
||||
it('creates the ADR-0014 tables and adds tone / tip_kinds_json on users', () => {
|
||||
it('creates the ADR-0014 tables, adds tone/tip_kinds_json, and drops legacy consent columns', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
@@ -26,6 +26,38 @@ describe('runMigrations — fresh DB', () => {
|
||||
const colNames = userCols.map((c) => c.name);
|
||||
expect(colNames).toContain('tone');
|
||||
expect(colNames).toContain('tip_kinds_json');
|
||||
// ADR-0014 step 8: legacy columns must be absent on a fresh DB
|
||||
expect(colNames).not.toContain('consent_given');
|
||||
expect(colNames).not.toContain('consent_at');
|
||||
});
|
||||
|
||||
it('drops consent columns from an existing DB that still had them', () => {
|
||||
const sqlite = freshDb();
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
// Simulate a pre-step-8 DB: create table with legacy columns and seed a user
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||
consent_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES ('u1', 'u@test.com', 'user', 1, '2026-04-01T00:00:00Z', '2026-03-01T00:00:00Z');
|
||||
`);
|
||||
runMigrations(sqlite);
|
||||
|
||||
const colNames = (sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]).map((c) => c.name);
|
||||
expect(colNames).not.toContain('consent_given');
|
||||
expect(colNames).not.toContain('consent_at');
|
||||
|
||||
// Backfill should have migrated the consent row before dropping
|
||||
const consent = sqlite
|
||||
.prepare(`SELECT consent_key FROM user_consents WHERE user_id = 'u1'`)
|
||||
.get() as { consent_key: string } | undefined;
|
||||
expect(consent?.consent_key).toBe('data:core');
|
||||
});
|
||||
|
||||
it('declares the expected composite primary keys', () => {
|
||||
@@ -53,71 +85,3 @@ describe('runMigrations — idempotency', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMigrations — consent backfill', () => {
|
||||
it('backfills users with consent_given=1 into user_consents (data:core)', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 1, ?, ?)`,
|
||||
).run('u1', 'u1@test.com', '2026-04-01T00:00:00Z', '2026-03-01T00:00:00Z');
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 0, NULL, ?)`,
|
||||
).run('u2', 'u2@test.com', '2026-03-02T00:00:00Z');
|
||||
|
||||
// Re-run migrations to trigger the backfill (the first call ran before users existed).
|
||||
runMigrations(sqlite);
|
||||
|
||||
const rows = sqlite
|
||||
.prepare(`SELECT user_id, consent_key, granted_at, revoked_at FROM user_consents`)
|
||||
.all() as { user_id: string; consent_key: string; granted_at: string; revoked_at: string | null }[];
|
||||
expect(rows).toEqual([
|
||||
{ user_id: 'u1', consent_key: 'data:core', granted_at: '2026-04-01T00:00:00Z', revoked_at: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to created_at when consent_at is null', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 1, NULL, ?)`,
|
||||
).run('u3', 'u3@test.com', '2026-02-15T00:00:00Z');
|
||||
|
||||
runMigrations(sqlite);
|
||||
|
||||
const granted = sqlite
|
||||
.prepare(`SELECT granted_at FROM user_consents WHERE user_id = 'u3'`)
|
||||
.get() as { granted_at: string };
|
||||
expect(granted.granted_at).toBe('2026-02-15T00:00:00Z');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing user_consents row on subsequent runs', () => {
|
||||
const sqlite = freshDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
sqlite.prepare(
|
||||
`INSERT INTO users (id, email, role, consent_given, consent_at, created_at)
|
||||
VALUES (?, ?, 'user', 1, ?, ?)`,
|
||||
).run('u4', 'u4@test.com', '2026-04-01T00:00:00Z', '2026-03-01T00:00:00Z');
|
||||
|
||||
runMigrations(sqlite);
|
||||
|
||||
// Simulate user revoking core consent later via the new code path.
|
||||
sqlite.prepare(
|
||||
`UPDATE user_consents SET revoked_at = ? WHERE user_id = 'u4' AND consent_key = 'data:core'`,
|
||||
).run('2026-04-15T00:00:00Z');
|
||||
|
||||
// Re-running migrations must not resurrect the consent (i.e. must not overwrite revoked_at).
|
||||
runMigrations(sqlite);
|
||||
|
||||
const row = sqlite
|
||||
.prepare(`SELECT granted_at, revoked_at FROM user_consents WHERE user_id = 'u4' AND consent_key = 'data:core'`)
|
||||
.get() as { granted_at: string; revoked_at: string | null };
|
||||
expect(row.revoked_at).toBe('2026-04-15T00:00:00Z');
|
||||
expect(row.granted_at).toBe('2026-04-01T00:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,6 @@ export function runMigrations(handle: BetterSqlite3Database) {
|
||||
image TEXT,
|
||||
google_id TEXT UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||
consent_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
@@ -199,16 +197,25 @@ export function runMigrations(handle: BetterSqlite3Database) {
|
||||
try { handle.exec(stmt); } catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// Backfill: ADR-0014 collapses users.consent_given into user_consents
|
||||
// (consent_key='data:core'). Idempotent — INSERT OR IGNORE on the
|
||||
// composite PK skips users already migrated. Stays in place until the
|
||||
// column is dropped (PR 6 of the migration plan).
|
||||
handle.exec(`
|
||||
INSERT OR IGNORE INTO user_consents (user_id, consent_key, granted_at)
|
||||
SELECT id, 'data:core', COALESCE(consent_at, created_at)
|
||||
FROM users
|
||||
WHERE consent_given = 1
|
||||
`);
|
||||
// Backfill (ADR-0014 step 2): migrate consent_given=1 rows into user_consents.
|
||||
// Wrapped in try/catch — silently skips on new DBs where consent_given never existed.
|
||||
try {
|
||||
handle.exec(`
|
||||
INSERT OR IGNORE INTO user_consents (user_id, consent_key, granted_at)
|
||||
SELECT id, 'data:core', COALESCE(consent_at, created_at)
|
||||
FROM users
|
||||
WHERE consent_given = 1
|
||||
`);
|
||||
} catch { /* column already dropped — nothing to backfill */ }
|
||||
|
||||
// Drop legacy consent columns (ADR-0014 step 8). Runs after the backfill above.
|
||||
// Silently skips if already dropped (column not found error) or never existed (new DB).
|
||||
for (const stmt of [
|
||||
`ALTER TABLE users DROP COLUMN consent_given`,
|
||||
`ALTER TABLE users DROP COLUMN consent_at`,
|
||||
]) {
|
||||
try { handle.exec(stmt); } catch { /* already dropped or never existed */ }
|
||||
}
|
||||
|
||||
// Seed first admin from env (ADMIN_SEED_EMAIL).
|
||||
const seedEmail = process.env.ADMIN_SEED_EMAIL;
|
||||
|
||||
@@ -7,10 +7,6 @@ export const users = sqliteTable('users', {
|
||||
image: text('image'),
|
||||
googleId: text('google_id').unique(),
|
||||
role: text('role').notNull().default('user'), // 'user' | 'admin'
|
||||
// Legacy single-bit consent. Superseded by user_consents (consent_key='data:core').
|
||||
// Kept for one release per ADR-0014 migration plan; reads consult both, writes go to user_consents only.
|
||||
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
|
||||
consentAt: text('consent_at'),
|
||||
// Stable globals (ADR-0014). Per-agent prefs land in user_preferences instead.
|
||||
tone: text('tone'), // 'direct' | 'gentle' | 'motivational'
|
||||
tipKindsJson: text('tip_kinds_json'), // JSON array of allowed tip kinds; null = all
|
||||
|
||||
Reference in New Issue
Block a user