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:
2026-05-05 11:50:27 +00:00
parent afb0e9b0cb
commit ed1705cb5d
15 changed files with 76 additions and 143 deletions

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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