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:'; // 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), 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(), });