From 5d433396162ede7d16fc3f35a0e2fa18ae523155 Mon Sep 17 00:00:00 2001 From: alvis Date: Tue, 5 May 2026 10:28:47 +0000 Subject: [PATCH] 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 --- .../api/src/db/__tests__/migrations.test.ts | 123 ++++++++++ services/api/src/db/index.ts | 170 +------------- services/api/src/db/migrations.ts | 218 ++++++++++++++++++ services/api/src/db/schema.ts | 38 +++ services/api/src/test/db.ts | 29 +++ 5 files changed, 410 insertions(+), 168 deletions(-) create mode 100644 services/api/src/db/__tests__/migrations.test.ts create mode 100644 services/api/src/db/migrations.ts diff --git a/services/api/src/db/__tests__/migrations.test.ts b/services/api/src/db/__tests__/migrations.test.ts new file mode 100644 index 0000000..df0541d --- /dev/null +++ b/services/api/src/db/__tests__/migrations.test.ts @@ -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'); + }); +}); diff --git a/services/api/src/db/index.ts b/services/api/src/db/index.ts index 43a18cf..21cee53 100644 --- a/services/api/src/db/index.ts +++ b/services/api/src/db/index.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import * as schema from './schema.js'; import { config } from '../config.js'; +import { runMigrations as runMigrationsImpl } from './migrations.js'; const sqlite = new Database(config.DATABASE_PATH); sqlite.pragma('journal_mode = WAL'); @@ -13,172 +14,5 @@ export const db = drizzle(sqlite, { schema }); export const rawSqlite: any = sqlite; export function runMigrations() { - sqlite.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); - `); - - // 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); - } + runMigrationsImpl(sqlite); } diff --git a/services/api/src/db/migrations.ts b/services/api/src/db/migrations.ts new file mode 100644 index 0000000..874c087 --- /dev/null +++ b/services/api/src/db/migrations.ts @@ -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); + } +} diff --git a/services/api/src/db/schema.ts b/services/api/src/db/schema.ts index b400eef..486ff5f 100644 --- a/services/api/src/db/schema.ts +++ b/services/api/src/db/schema.ts @@ -7,12 +7,50 @@ 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 createdAt: text('created_at').notNull(), deletedAt: text('deleted_at'), }); +// ── Unified Profile model (ADR-0014) ──────────────────────────────────────── +// Open-ended per-scope preferences. `scope` is 'orchestrator' or 'agent:'; +// 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:' + 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:' | … + 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', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), diff --git a/services/api/src/test/db.ts b/services/api/src/test/db.ts index f88e8ca..d57f552 100644 --- a/services/api/src/test/db.ts +++ b/services/api/src/test/db.ts @@ -22,6 +22,8 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } { role TEXT NOT NULL DEFAULT 'user', consent_given INTEGER NOT NULL DEFAULT 0, consent_at TEXT, + tone TEXT, + tip_kinds_json TEXT, created_at TEXT NOT NULL, deleted_at TEXT ); @@ -142,6 +144,33 @@ export function makeTestDb(): DrizzleDb & { rawSqlite: BetterSqlite3Database } { 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 ( id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES sim_runs(id),