Files
oO/services/api/src/db/schema.ts
alvis ed1705cb5d 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>
2026-05-05 11:50:27 +00:00

200 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
image: text('image'),
googleId: text('google_id').unique(),
role: text('role').notNull().default('user'), // 'user' | 'admin'
// 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:<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', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
provider: text('provider').notNull(), // 'todoist'
accessToken: text('access_token').notNull(),
refreshToken: text('refresh_token'),
expiresAt: text('expires_at'),
tokenStatus: text('token_status').notNull().default('active'), // 'active' | 'needs_reconnect'
connectedAt: text('connected_at').notNull(),
});
export const tipFeedback = sqliteTable('tip_feedback', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
tipId: text('tip_id').notNull(),
action: text('action').notNull(), // 'done' | 'dismiss' | 'snooze'
sourceId: text('source_id'),
dwellMs: integer('dwell_ms'), // ms between servedAt and feedback; null if unknown
rewardMilli: integer('reward_milli'), // inferred reward × 1000 (e.g. 1000 = +1.0)
createdAt: text('created_at').notNull(),
});
// Each row = one tip served. Join with tipFeedback on tipId to compute reaction rate + dwell.
export const tipViews = sqliteTable('tip_views', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
tipId: text('tip_id').notNull(),
servedAt: text('served_at').notNull(),
});
export const pushSubscriptions = sqliteTable('push_subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
endpoint: text('endpoint').notNull().unique(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
createdAt: text('created_at').notNull(),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull(),
});
// Audit log — every admin write action is appended here.
export const adminActions = sqliteTable('admin_actions', {
id: text('id').primaryKey(),
adminId: text('admin_id').notNull().references(() => users.id),
action: text('action').notNull(), // e.g. 'revoke_token', 'reset_bandit'
targetType: text('target_type'), // e.g. 'user', 'integration'
targetId: text('target_id'),
detail: text('detail'), // JSON blob for extra context
createdAt: text('created_at').notNull(),
});
// Recommendation explainability log — one row per tip served.
// features/scores are JSON blobs. Retained 30 days (GDPR).
export const tipScores = sqliteTable('tip_scores', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
tipId: text('tip_id').notNull(),
policy: text('policy').notNull(),
mlScore: integer('ml_score', { mode: 'number' }), // null when random fallback
featuresJson: text('features_json'), // JSON: { is_overdue, task_age_days, priority, hour_of_day, day_of_week }
candidateCount: integer('candidate_count'),
latencyMs: integer('latency_ms'),
servedAt: text('served_at').notNull(),
promptVersion: text('prompt_version'), // e.g. 'v1' — tracks which prompt template generated this tip
llmModel: text('llm_model'), // e.g. 'tip-generator/qwen2.5:7b' — null for bandit-only tips
tipKind: text('tip_kind'), // 'task' | 'advice' | 'insight' | 'reminder'
});
// ── User profile features (#81 phase A) ────────────────────────────────────
// One row per (userId, name). KV store for aggregated user-level features
// computed from tip_views/tip_feedback/tip_scores. Numeric values land in
// `value`; categorical/string values use `value_text` (never both). Entries
// are recomputed lazily by the profile builder when older than `ttl_sec`.
export const userProfileFeatures = sqliteTable('user_profile_features', {
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(), // e.g. 'completion_rate_30d'
value: integer('value', { mode: 'number' }), // numeric (REAL stored as number); null if categorical
valueText: text('value_text'), // categorical/string; null if numeric
updatedAt: text('updated_at').notNull(),
ttlSec: integer('ttl_sec').notNull(), // staleness threshold; 0 = never auto-refresh
});
// ── Simulation runs ──────────────────────────────────────────────────────────
// One row per offline simulation run (two-policy comparison).
export const simRuns = sqliteTable('sim_runs', {
id: text('id').primaryKey(),
policyA: text('policy_a').notNull(),
policyB: text('policy_b').notNull(),
nUsers: integer('n_users').notNull(),
nRounds: integer('n_rounds').notNull(),
tasksPerRound: integer('tasks_per_round').notNull().default(8),
useLlm: integer('use_llm', { mode: 'boolean' }).notNull().default(false),
status: text('status').notNull().default('pending'), // 'pending'|'running'|'done'|'failed'
judgeMode: text('judge_mode').notNull().default('rule'),
nPolicies: integer('n_policies').notNull().default(2),
summaryJson: text('summary_json'), // JSON: { [policy]: PolicySummary }
winner: text('winner'),
personaBreakdownJson: text('persona_breakdown_json'), // JSON: { [persona]: { [policy]: {reward,n} } }
mlflowRunId: text('mlflow_run_id'),
createdAt: text('created_at').notNull(),
finishedAt: text('finished_at'),
});
// One row per tip served in a simulation round.
export const simEvents = sqliteTable('sim_events', {
id: text('id').primaryKey(),
runId: text('run_id').notNull().references(() => simRuns.id),
round: integer('round').notNull(),
userId: text('user_id').notNull(),
persona: text('persona').notNull(),
policy: text('policy').notNull(),
tipContent: text('tip_content').notNull(),
priority: integer('priority').notNull(),
isOverdue: integer('is_overdue', { mode: 'boolean' }).notNull(),
action: text('action').notNull(), // 'done' | 'snooze' | 'dismiss'
dwellMs: integer('dwell_ms'), // simulated ms between tip appear and user action
rewardMilli: integer('reward_milli').notNull(), // inferred reward × 1000
hour: integer('hour').notNull(),
dayOfWeek: integer('day_of_week').notNull(),
createdAt: text('created_at').notNull(),
});
// ── Agent outputs (#multi-agent) ─────────────────────────────────────────────
// One row per (userId, agentId) pre-compute run. The orchestrator reads the
// freshest non-expired row per agent when assembling the tip prompt.
export const agentOutputs = sqliteTable('agent_outputs', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
agentId: text('agent_id').notNull(), // e.g. 'overdue-task'
promptText: text('prompt_text').notNull(), // snippet for orchestrator prompt
signalsSnapshot: text('signals_snapshot'), // JSON: inputs the agent consumed
computedAt: text('computed_at').notNull(), // ISO 8601
expiresAt: text('expires_at').notNull(), // ISO 8601 = computedAt + TTL
agentVersion: text('agent_version').notNull(), // bump to invalidate on logic changes
});
// Admin saved SQL queries.
export const savedQueries = sqliteTable('saved_queries', {
id: text('id').primaryKey(),
adminId: text('admin_id').notNull().references(() => users.id),
name: text('name').notNull(),
sql: text('sql').notNull(),
createdAt: text('created_at').notNull(),
});