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>
200 lines
9.9 KiB
TypeScript
200 lines
9.9 KiB
TypeScript
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(),
|
||
});
|