feat(api): unified Profile schema + consent backfill (ADR-0014 step 1-2)
Adds user_preferences, user_consents, user_contexts and the tone / tip_kinds_json columns on users. Backfills consent_given=1 rows into user_consents as data:core; INSERT OR IGNORE keeps it idempotent and respects later revocations. Migration body moves to db/migrations.ts so tests can apply it to a fresh in-memory handle without opening the prod DB on import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
123
services/api/src/db/__tests__/migrations.test.ts
Normal file
123
services/api/src/db/__tests__/migrations.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Migration tests — apply runMigrations() to a fresh in-memory SQLite handle
|
||||||
|
* and verify schema, idempotency, and the consent_given → user_consents backfill.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { runMigrations } from '../migrations.js';
|
||||||
|
|
||||||
|
function freshDb() {
|
||||||
|
const sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
return sqlite;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runMigrations — fresh DB', () => {
|
||||||
|
it('creates the ADR-0014 tables and adds tone / tip_kinds_json on users', () => {
|
||||||
|
const sqlite = freshDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const tables = (sqlite
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table'`)
|
||||||
|
.all() as { name: string }[]).map((r) => r.name);
|
||||||
|
expect(tables).toEqual(expect.arrayContaining(['user_preferences', 'user_consents', 'user_contexts']));
|
||||||
|
|
||||||
|
const userCols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
|
||||||
|
const colNames = userCols.map((c) => c.name);
|
||||||
|
expect(colNames).toContain('tone');
|
||||||
|
expect(colNames).toContain('tip_kinds_json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares the expected composite primary keys', () => {
|
||||||
|
const sqlite = freshDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
type ColInfo = { name: string; pk: number };
|
||||||
|
const pkCols = (table: string): string[] =>
|
||||||
|
(sqlite.prepare(`PRAGMA table_info(${table})`).all() as ColInfo[])
|
||||||
|
.filter((c) => c.pk > 0)
|
||||||
|
.sort((a, b) => a.pk - b.pk)
|
||||||
|
.map((c) => c.name);
|
||||||
|
|
||||||
|
expect(pkCols('user_preferences')).toEqual(['user_id', 'scope', 'key']);
|
||||||
|
expect(pkCols('user_consents')).toEqual(['user_id', 'consent_key']);
|
||||||
|
expect(pkCols('user_contexts')).toEqual(['user_id', 'name']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runMigrations — idempotency', () => {
|
||||||
|
it('is safe to re-run on an already-migrated DB', () => {
|
||||||
|
const sqlite = freshDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
expect(() => runMigrations(sqlite)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { runMigrations as runMigrationsImpl } from './migrations.js';
|
||||||
|
|
||||||
const sqlite = new Database(config.DATABASE_PATH);
|
const sqlite = new Database(config.DATABASE_PATH);
|
||||||
sqlite.pragma('journal_mode = WAL');
|
sqlite.pragma('journal_mode = WAL');
|
||||||
@@ -13,172 +14,5 @@ export const db = drizzle(sqlite, { schema });
|
|||||||
export const rawSqlite: any = sqlite;
|
export const rawSqlite: any = sqlite;
|
||||||
|
|
||||||
export function runMigrations() {
|
export function runMigrations() {
|
||||||
sqlite.exec(`
|
runMigrationsImpl(sqlite);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT,
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
access_token TEXT NOT NULL,
|
|
||||||
refresh_token TEXT,
|
|
||||||
expires_at TEXT,
|
|
||||||
connected_at TEXT NOT NULL,
|
|
||||||
UNIQUE(user_id, provider)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tip_feedback (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
tip_id TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
source_id TEXT,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tip_views (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
tip_id TEXT NOT NULL,
|
|
||||||
served_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
endpoint TEXT NOT NULL UNIQUE,
|
|
||||||
p256dh TEXT NOT NULL,
|
|
||||||
auth TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_actions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
admin_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
target_type TEXT,
|
|
||||||
target_id TEXT,
|
|
||||||
detail TEXT,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tip_scores (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
tip_id TEXT NOT NULL,
|
|
||||||
policy TEXT NOT NULL,
|
|
||||||
ml_score INTEGER,
|
|
||||||
features_json TEXT,
|
|
||||||
candidate_count INTEGER,
|
|
||||||
latency_ms INTEGER,
|
|
||||||
served_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS saved_queries (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
admin_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sql TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_profile_features (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
value REAL,
|
|
||||||
value_text TEXT,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
ttl_sec INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sim_runs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
policy_a TEXT NOT NULL,
|
|
||||||
policy_b TEXT NOT NULL,
|
|
||||||
n_users INTEGER NOT NULL,
|
|
||||||
n_rounds INTEGER NOT NULL,
|
|
||||||
tasks_per_round INTEGER NOT NULL DEFAULT 8,
|
|
||||||
use_llm INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
summary_json TEXT,
|
|
||||||
winner TEXT,
|
|
||||||
persona_breakdown_json TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
finished_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sim_events (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
|
||||||
round INTEGER NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
persona TEXT NOT NULL,
|
|
||||||
policy TEXT NOT NULL,
|
|
||||||
tip_content TEXT NOT NULL,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
is_overdue INTEGER NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
dwell_ms INTEGER,
|
|
||||||
reward_milli INTEGER NOT NULL,
|
|
||||||
hour INTEGER NOT NULL,
|
|
||||||
day_of_week INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_outputs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
agent_id TEXT NOT NULL,
|
|
||||||
prompt_text TEXT NOT NULL,
|
|
||||||
signals_snapshot TEXT,
|
|
||||||
computed_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
agent_version TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_outputs_user_agent_exp
|
|
||||||
ON agent_outputs(user_id, agent_id, expires_at DESC);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Additive column migrations — safe to run on existing DBs.
|
|
||||||
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
|
|
||||||
for (const stmt of [
|
|
||||||
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
|
|
||||||
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE tip_feedback ADD COLUMN dwell_ms INTEGER`,
|
|
||||||
`ALTER TABLE tip_feedback ADD COLUMN reward_milli INTEGER`,
|
|
||||||
`ALTER TABLE integration_tokens ADD COLUMN token_status TEXT NOT NULL DEFAULT 'active'`,
|
|
||||||
`ALTER TABLE tip_scores ADD COLUMN prompt_version TEXT`,
|
|
||||||
`ALTER TABLE tip_scores ADD COLUMN llm_model TEXT`,
|
|
||||||
`ALTER TABLE tip_scores ADD COLUMN tip_kind TEXT`,
|
|
||||||
`ALTER TABLE sim_runs ADD COLUMN mlflow_run_id TEXT`,
|
|
||||||
`ALTER TABLE sim_runs ADD COLUMN judge_mode TEXT NOT NULL DEFAULT 'rule'`,
|
|
||||||
`ALTER TABLE sim_runs ADD COLUMN n_policies INTEGER NOT NULL DEFAULT 2`,
|
|
||||||
]) {
|
|
||||||
try { sqlite.exec(stmt); } catch { /* column already exists */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed first admin from env (ADMIN_SEED_EMAIL).
|
|
||||||
const seedEmail = process.env.ADMIN_SEED_EMAIL;
|
|
||||||
if (seedEmail) {
|
|
||||||
sqlite.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
218
services/api/src/db/migrations.ts
Normal file
218
services/api/src/db/migrations.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Schema migrations and one-shot backfills for the API DB.
|
||||||
|
*
|
||||||
|
* Kept separate from db/index.ts so tests can apply migrations to an in-memory
|
||||||
|
* SQLite handle without triggering the singleton DB connection at import time.
|
||||||
|
*/
|
||||||
|
import type { Database as BetterSqlite3Database } from 'better-sqlite3';
|
||||||
|
|
||||||
|
export function runMigrations(handle: BetterSqlite3Database) {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
connected_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tip_feedback (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
tip_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tip_views (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
tip_id TEXT NOT NULL,
|
||||||
|
served_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tip_scores (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
tip_id TEXT NOT NULL,
|
||||||
|
policy TEXT NOT NULL,
|
||||||
|
ml_score INTEGER,
|
||||||
|
features_json TEXT,
|
||||||
|
candidate_count INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
served_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_queries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
admin_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sql TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profile_features (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
value REAL,
|
||||||
|
value_text TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
ttl_sec INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sim_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
policy_a TEXT NOT NULL,
|
||||||
|
policy_b TEXT NOT NULL,
|
||||||
|
n_users INTEGER NOT NULL,
|
||||||
|
n_rounds INTEGER NOT NULL,
|
||||||
|
tasks_per_round INTEGER NOT NULL DEFAULT 8,
|
||||||
|
use_llm INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
summary_json TEXT,
|
||||||
|
winner TEXT,
|
||||||
|
persona_breakdown_json TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sim_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
||||||
|
round INTEGER NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
persona TEXT NOT NULL,
|
||||||
|
policy TEXT NOT NULL,
|
||||||
|
tip_content TEXT NOT NULL,
|
||||||
|
priority INTEGER NOT NULL,
|
||||||
|
is_overdue INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
dwell_ms INTEGER,
|
||||||
|
reward_milli INTEGER NOT NULL,
|
||||||
|
hour INTEGER NOT NULL,
|
||||||
|
day_of_week INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_outputs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
prompt_text TEXT NOT NULL,
|
||||||
|
signals_snapshot TEXT,
|
||||||
|
computed_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
agent_version TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_outputs_user_agent_exp
|
||||||
|
ON agent_outputs(user_id, agent_id, expires_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value_json TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'user',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, scope, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_consents (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
consent_key TEXT NOT NULL,
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
PRIMARY KEY (user_id, consent_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_contexts (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
schedule_json TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, name)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Additive column migrations — safe to run on existing DBs.
|
||||||
|
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
|
||||||
|
for (const stmt of [
|
||||||
|
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
|
||||||
|
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE tip_feedback ADD COLUMN dwell_ms INTEGER`,
|
||||||
|
`ALTER TABLE tip_feedback ADD COLUMN reward_milli INTEGER`,
|
||||||
|
`ALTER TABLE integration_tokens ADD COLUMN token_status TEXT NOT NULL DEFAULT 'active'`,
|
||||||
|
`ALTER TABLE tip_scores ADD COLUMN prompt_version TEXT`,
|
||||||
|
`ALTER TABLE tip_scores ADD COLUMN llm_model TEXT`,
|
||||||
|
`ALTER TABLE tip_scores ADD COLUMN tip_kind TEXT`,
|
||||||
|
`ALTER TABLE sim_runs ADD COLUMN mlflow_run_id TEXT`,
|
||||||
|
`ALTER TABLE sim_runs ADD COLUMN judge_mode TEXT NOT NULL DEFAULT 'rule'`,
|
||||||
|
`ALTER TABLE sim_runs ADD COLUMN n_policies INTEGER NOT NULL DEFAULT 2`,
|
||||||
|
`ALTER TABLE users ADD COLUMN tone TEXT`,
|
||||||
|
`ALTER TABLE users ADD COLUMN tip_kinds_json TEXT`,
|
||||||
|
]) {
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed first admin from env (ADMIN_SEED_EMAIL).
|
||||||
|
const seedEmail = process.env.ADMIN_SEED_EMAIL;
|
||||||
|
if (seedEmail) {
|
||||||
|
handle.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,50 @@ export const users = sqliteTable('users', {
|
|||||||
image: text('image'),
|
image: text('image'),
|
||||||
googleId: text('google_id').unique(),
|
googleId: text('google_id').unique(),
|
||||||
role: text('role').notNull().default('user'), // 'user' | 'admin'
|
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),
|
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
|
||||||
consentAt: text('consent_at'),
|
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
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
deletedAt: text('deleted_at'),
|
deletedAt: text('deleted_at'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Unified Profile model (ADR-0014) ────────────────────────────────────────
|
||||||
|
// Open-ended per-scope preferences. `scope` is 'orchestrator' or 'agent:<id>';
|
||||||
|
// the agent's pref_schema (from its manifest) validates value_json on read.
|
||||||
|
// `source='inferred'` is written by the inference framework (#111); never
|
||||||
|
// overwrites a `source='user'` row.
|
||||||
|
export const userPreferences = sqliteTable('user_preferences', {
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
scope: text('scope').notNull(), // 'orchestrator' | 'agent:<id>'
|
||||||
|
key: text('key').notNull(),
|
||||||
|
valueJson: text('value_json').notNull(),
|
||||||
|
source: text('source').notNull().default('user'), // 'user' | 'inferred'
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-key consent. Revocation writes `revoked_at`; rows are never deleted
|
||||||
|
// so audits stay clean. `revoked_at IS NULL` = currently active.
|
||||||
|
export const userConsents = sqliteTable('user_consents', {
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
consentKey: text('consent_key').notNull(), // 'data:core' | 'data:todoist' | 'agent:<id>' | …
|
||||||
|
grantedAt: text('granted_at').notNull(),
|
||||||
|
revokedAt: text('revoked_at'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User-named contexts (work / home / vacation). M2 ships manual toggle only;
|
||||||
|
// auto-inference is per-agent (#112–#116).
|
||||||
|
export const userContexts = sqliteTable('user_contexts', {
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
active: integer('active', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
scheduleJson: text('schedule_json'), // optional: when active
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const integrationTokens = sqliteTable('integration_tokens', {
|
export const integrationTokens = sqliteTable('integration_tokens', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } {
|
|||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
consent_given INTEGER NOT NULL DEFAULT 0,
|
consent_given INTEGER NOT NULL DEFAULT 0,
|
||||||
consent_at TEXT,
|
consent_at TEXT,
|
||||||
|
tone TEXT,
|
||||||
|
tip_kinds_json TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
@@ -142,6 +144,33 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } {
|
|||||||
agent_version TEXT NOT NULL
|
agent_version TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value_json TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'user',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, scope, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_consents (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
consent_key TEXT NOT NULL,
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
PRIMARY KEY (user_id, consent_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_contexts (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
schedule_json TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sim_events (
|
CREATE TABLE IF NOT EXISTS sim_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
||||||
|
|||||||
Reference in New Issue
Block a user