diff --git a/apps/admin/src/app/audit/page.tsx b/apps/admin/src/app/audit/page.tsx
new file mode 100644
index 0000000..de73ab4
--- /dev/null
+++ b/apps/admin/src/app/audit/page.tsx
@@ -0,0 +1,12 @@
+import { AdminShell } from '@/components/AdminShell';
+import { AuditLog } from '@/components/AuditLog';
+
+export const dynamic = 'force-dynamic';
+
+export default function AuditPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/data-quality/page.tsx b/apps/admin/src/app/data-quality/page.tsx
new file mode 100644
index 0000000..5b3fb6f
--- /dev/null
+++ b/apps/admin/src/app/data-quality/page.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { getDataQuality } from '@/lib/api';
+
+function Pct({ value }: { value: number }) {
+ const pct = (value * 100).toFixed(1);
+ const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400';
+ return {pct}% ;
+}
+
+export default function DataQualityPage() {
+ const [data, setData] = useState> | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ getDataQuality()
+ .then(setData)
+ .catch((e) => setError(e.message))
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+
+
Data quality
+ {error &&
{error}
}
+ {loading &&
Loading…
}
+
+ {data && (
+ <>
+
+
+
Scoring calls (30d)
+
{data.scoringCallsLast30d}
+
+
+
Missing feature rate
+
+
+
+
Integration tokens
+
{data.totalTokens}
+
+
+
Stale token rate (>7d)
+
+
+
+
+
+
Daily feature completeness (14d)
+
+
+
+ Date
+ Scoring calls
+ With features
+ Coverage
+ Avg candidates
+
+
+
+ {data.dailyQuality.map((row) => {
+ const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
+ return (
+
+ {row.date}
+ {row.total}
+ {row.withFeatures}
+
+ {row.avgCandidates?.toFixed(1) ?? '—'}
+
+ );
+ })}
+ {data.dailyQuality.length === 0 && (
+ No data yet
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/docs/[category]/[slug]/page.tsx b/apps/admin/src/app/docs/[category]/[slug]/page.tsx
new file mode 100644
index 0000000..b9f35da
--- /dev/null
+++ b/apps/admin/src/app/docs/[category]/[slug]/page.tsx
@@ -0,0 +1,73 @@
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+import { AdminShell } from '@/components/AdminShell';
+import { getDoc, type DocCategory } from '@/lib/docs';
+
+export const dynamic = 'force-dynamic';
+
+const CATEGORY_LABELS: Record = {
+ adr: 'ADR',
+ architecture: 'Architecture',
+};
+
+function isDocCategory(value: string): value is DocCategory {
+ return value === 'adr' || value === 'architecture';
+}
+
+export default async function DocDetailPage({
+ params,
+}: {
+ params: { category: string; slug: string };
+}) {
+ if (!isDocCategory(params.category)) notFound();
+
+ const doc = await getDoc(params.category, params.slug);
+ if (!doc) notFound();
+
+ const categoryLabel = CATEGORY_LABELS[params.category];
+
+ return (
+
+
+ {/* Breadcrumb */}
+
+
+ Docs
+
+ /
+ {categoryLabel}
+ /
+ {doc.slug}
+
+
+ {/* Meta bar */}
+ {(doc.status || doc.date) && (
+
+ {doc.status && (
+
+ {doc.status}
+
+ )}
+ {doc.date && {doc.date} }
+
+ )}
+
+ {/* Markdown body */}
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/docs/page.tsx b/apps/admin/src/app/docs/page.tsx
new file mode 100644
index 0000000..130486a
--- /dev/null
+++ b/apps/admin/src/app/docs/page.tsx
@@ -0,0 +1,82 @@
+import Link from 'next/link';
+import { AdminShell } from '@/components/AdminShell';
+import { listAllDocs, type DocMeta } from '@/lib/docs';
+
+export const dynamic = 'force-dynamic';
+
+function StatusBadge({ status }: { status?: string }) {
+ if (!status) return null;
+ const color =
+ status === 'Accepted'
+ ? 'bg-emerald-900 text-emerald-300'
+ : status === 'Proposed'
+ ? 'bg-yellow-900 text-yellow-300'
+ : status === 'Deprecated'
+ ? 'bg-red-900 text-red-400'
+ : 'bg-gray-800 text-gray-400';
+ return (
+ {status}
+ );
+}
+
+function DocList({ docs, emptyText }: { docs: DocMeta[]; emptyText: string }) {
+ if (docs.length === 0) {
+ return {emptyText}
;
+ }
+ return (
+
+ {docs.map((doc) => (
+
+
+ {doc.title}
+
+ {doc.date && (
+
+ {doc.date}
+
+ )}
+
+
+ ))}
+
+ );
+}
+
+export default async function DocsPage() {
+ const { adr, architecture } = await listAllDocs();
+
+ return (
+
+
+
+
Docs
+
+ Architecture Decision Records and design notes from{' '}
+ docs/
+
+
+
+
+
+ Architecture Decision Records
+
+
+
+
+
+
+
+
+ Architecture notes
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/events/page.tsx b/apps/admin/src/app/events/page.tsx
new file mode 100644
index 0000000..82b940b
--- /dev/null
+++ b/apps/admin/src/app/events/page.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { getEvents, StoredEvent } from '@/lib/api';
+
+const SUBJECTS = ['', 'signals.tip', 'signals.task', 'signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
+
+export default function EventsPage() {
+ const [events, setEvents] = useState([]);
+ const [subject, setSubject] = useState('');
+ const [userId, setUserId] = useState('');
+ const [live, setLive] = useState(true);
+ const [error, setError] = useState('');
+ const sinceRef = useRef(0);
+ const timerRef = useRef | null>(null);
+
+ const fetchEvents = async (reset = false) => {
+ try {
+ const since = reset ? 0 : sinceRef.current;
+ const res = await getEvents({ subject: subject || undefined, userId: userId || undefined, limit: 100, since });
+ sinceRef.current = res.nextSince;
+ setEvents((prev) => {
+ const next = reset ? res.events : [...prev, ...res.events];
+ return next.slice(-500); // keep last 500
+ });
+ setError('');
+ } catch (e: any) {
+ setError(e.message);
+ }
+ };
+
+ useEffect(() => {
+ sinceRef.current = 0;
+ fetchEvents(true);
+ }, [subject, userId]);
+
+ useEffect(() => {
+ if (live) {
+ timerRef.current = setInterval(() => fetchEvents(false), 2000);
+ } else if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ return () => { if (timerRef.current) clearInterval(timerRef.current); };
+ }, [live, subject, userId]);
+
+ return (
+
+
+
+
Event stream
+
+
+ setLive(e.target.checked)} className="accent-indigo-500" />
+ Live
+
+ { sinceRef.current = 0; fetchEvents(true); }} className="text-xs text-gray-400 hover:text-white border border-gray-700 rounded px-2 py-1">
+ Refresh
+
+
+
+
+
+ setSubject(e.target.value)} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
+ {SUBJECTS.map((s) => {s || 'All subjects'} )}
+
+ setUserId(e.target.value)}
+ placeholder="Filter by user ID"
+ className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300 w-64"
+ />
+
+
+ {error &&
{error}
}
+
+
+ {events.length === 0 && (
+
No events yet. Waiting…
+ )}
+ {[...events].reverse().map((e) => (
+
+ {e.id}
+ {e.ts.slice(11, 19)}
+ {e.subject}
+ {JSON.stringify(e.payload)}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/experiments/page.tsx b/apps/admin/src/app/experiments/page.tsx
new file mode 100644
index 0000000..bb8bb26
--- /dev/null
+++ b/apps/admin/src/app/experiments/page.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { resetBandit } from '@/lib/api';
+
+interface BanditStats {
+ user_id: string;
+ pulls: number;
+ reward_count: number;
+ cumulative_reward: number;
+ estimated_mean_reward: number;
+ theta: number[];
+ last_updated: string | null;
+}
+
+const FEATURE_LABELS = ['hour_sin', 'hour_cos', 'is_overdue', 'task_age', 'priority'];
+
+export default function ExperimentsPage() {
+ const [userId, setUserId] = useState('');
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [resetting, setResetting] = useState(false);
+ const [error, setError] = useState('');
+ const [resetMsg, setResetMsg] = useState('');
+
+ const fetchStats = async () => {
+ if (!userId.trim()) return;
+ setLoading(true);
+ setError('');
+ try {
+ const res = await fetch(`/api/ml/stats/${encodeURIComponent(userId.trim())}`, { credentials: 'include' });
+ if (!res.ok) throw new Error(res.statusText);
+ setStats(await res.json());
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleReset = async () => {
+ if (!userId.trim()) return;
+ if (!confirm(`Reset LinUCB state for user ${userId}?`)) return;
+ setResetting(true);
+ try {
+ await resetBandit(userId.trim());
+ setResetMsg('Bandit state reset.');
+ setStats(null);
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setResetting(false);
+ }
+ };
+
+ return (
+
+
+
Experiment dashboard
+
LinUCB per-user bandit stats pulled from ml/serving.
+
+
+ setUserId(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && fetchStats()}
+ placeholder="User ID"
+ className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-80"
+ />
+
+ Load
+
+ {stats && (
+
+ Reset bandit
+
+ )}
+
+
+ {error &&
{error}
}
+ {resetMsg &&
{resetMsg}
}
+ {loading &&
Loading…
}
+
+ {stats && (
+
+
+
+
+
+
+ )}
+
+ {stats?.theta && (
+
+
θ (learned weight vector)
+
+ {stats.theta.map((v, i) => (
+
+
{FEATURE_LABELS[i] ?? `feat_${i}`}
+
0 ? 'text-green-400' : v < 0 ? 'text-red-400' : 'text-gray-400'}`}>
+ {v.toFixed(4)}
+
+
+ ))}
+
+ {stats.last_updated && (
+
Last updated: {stats.last_updated}
+ )}
+
+ )}
+
+
+ );
+}
+
+function StatCard({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/app/features/page.tsx b/apps/admin/src/app/features/page.tsx
new file mode 100644
index 0000000..a32d43d
--- /dev/null
+++ b/apps/admin/src/app/features/page.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+
+interface FeatureEntry {
+ ts: string;
+ features: Record;
+ score: number;
+ tip_id: string;
+}
+
+const FEATURE_NAMES = ['hour_of_day', 'is_overdue', 'task_age_days', 'priority'];
+
+export default function FeaturesPage() {
+ const [userId, setUserId] = useState('');
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const fetch_ = async () => {
+ if (!userId.trim()) return;
+ setLoading(true);
+ setError('');
+ try {
+ const res = await fetch(`/api/ml/features/${encodeURIComponent(userId.trim())}`, { credentials: 'include' });
+ if (!res.ok) throw new Error(res.statusText);
+ const data = await res.json();
+ setHistory(data.history ?? []);
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Feature store browser
+
+ Features sent to ml/serving per scoring call for a user. Shows last 100 entries.
+
+
+
+ setUserId(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && fetch_()}
+ placeholder="User ID"
+ className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-80"
+ />
+
+ Load
+
+
+
+ {error &&
{error}
}
+ {loading &&
Loading…
}
+
+ {history.length > 0 && (
+
+
+
+
+ Time
+ Score
+ {FEATURE_NAMES.map((f) => (
+ {f}
+ ))}
+ Tip ID
+
+
+
+ {[...history].reverse().map((entry, i) => (
+
+ {entry.ts.slice(11, 19)}
+ {entry.score.toFixed(4)}
+ {FEATURE_NAMES.map((f) => (
+
+ {String(entry.features[f] ?? '—')}
+
+ ))}
+ {entry.tip_id}
+
+ ))}
+
+
+
+ )}
+
+ {history.length === 0 && !loading && userId && (
+
No scoring history for this user yet.
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/forbidden/page.tsx b/apps/admin/src/app/forbidden/page.tsx
new file mode 100644
index 0000000..dcdf870
--- /dev/null
+++ b/apps/admin/src/app/forbidden/page.tsx
@@ -0,0 +1,10 @@
+export default function ForbiddenPage() {
+ return (
+
+
+
403 — Forbidden
+
Your account does not have admin access.
+
+
+ );
+}
diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css
new file mode 100644
index 0000000..7a9eadf
--- /dev/null
+++ b/apps/admin/src/app/globals.css
@@ -0,0 +1,123 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: system-ui, -apple-system, sans-serif;
+}
+
+/* -------------------------------------------------------------------------
+ Markdown prose — used on /docs pages via the .prose-doc class.
+ Purposely minimal: dark theme, respects the existing gray palette.
+ ------------------------------------------------------------------------- */
+.prose-doc {
+ color: #d1d5db; /* gray-300 */
+ line-height: 1.7;
+ font-size: 0.9375rem;
+}
+
+.prose-doc h1,
+.prose-doc h2,
+.prose-doc h3,
+.prose-doc h4 {
+ color: #f3f4f6; /* gray-100 */
+ font-weight: 600;
+ margin-top: 1.75em;
+ margin-bottom: 0.5em;
+ line-height: 1.3;
+}
+.prose-doc h1 { font-size: 1.5rem; margin-top: 0; border-bottom: 1px solid #374151; padding-bottom: 0.4em; }
+.prose-doc h2 { font-size: 1.2rem; }
+.prose-doc h3 { font-size: 1.05rem; }
+.prose-doc h4 { font-size: 0.95rem; }
+
+.prose-doc p { margin-top: 0.75em; margin-bottom: 0.75em; }
+.prose-doc p:first-child { margin-top: 0; }
+
+.prose-doc a {
+ color: #818cf8; /* indigo-400 */
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.prose-doc a:hover { color: #a5b4fc; }
+
+.prose-doc code {
+ background: #1f2937; /* gray-800 */
+ border-radius: 3px;
+ padding: 0.15em 0.4em;
+ font-size: 0.85em;
+ color: #e5e7eb;
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
+}
+
+.prose-doc pre {
+ background: #111827; /* gray-900 */
+ border: 1px solid #374151;
+ border-radius: 6px;
+ padding: 1em 1.25em;
+ overflow-x: auto;
+ margin: 1.25em 0;
+}
+.prose-doc pre code {
+ background: none;
+ padding: 0;
+ font-size: 0.85em;
+ color: #d1d5db;
+}
+
+.prose-doc ul,
+.prose-doc ol {
+ padding-left: 1.5em;
+ margin: 0.75em 0;
+}
+.prose-doc li { margin: 0.25em 0; }
+.prose-doc ul { list-style-type: disc; }
+.prose-doc ol { list-style-type: decimal; }
+
+.prose-doc blockquote {
+ border-left: 3px solid #4b5563;
+ margin: 1em 0;
+ padding: 0.25em 1em;
+ color: #9ca3af;
+ font-style: italic;
+}
+
+.prose-doc table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.25em 0;
+ font-size: 0.875rem;
+}
+.prose-doc th {
+ text-align: left;
+ padding: 0.5em 0.75em;
+ background: #1f2937;
+ color: #9ca3af;
+ font-weight: 500;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid #374151;
+}
+.prose-doc td {
+ padding: 0.5em 0.75em;
+ border-bottom: 1px solid #1f2937;
+ vertical-align: top;
+}
+.prose-doc tr:last-child td { border-bottom: none; }
+
+.prose-doc hr {
+ border: none;
+ border-top: 1px solid #374151;
+ margin: 2em 0;
+}
+
+.prose-doc strong { color: #f3f4f6; font-weight: 600; }
+.prose-doc em { color: #d1d5db; }
diff --git a/apps/admin/src/app/health/page.tsx b/apps/admin/src/app/health/page.tsx
new file mode 100644
index 0000000..f345299
--- /dev/null
+++ b/apps/admin/src/app/health/page.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { getHealth, HealthStatus } from '@/lib/api';
+
+const STATUS_STYLES: Record = {
+ ok: 'bg-green-900 text-green-300 border-green-800',
+ degraded: 'bg-yellow-900 text-yellow-300 border-yellow-800',
+ down: 'bg-red-900 text-red-300 border-red-800',
+};
+
+export default function HealthPage() {
+ const [health, setHealth] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ const refresh = () => {
+ setLoading(true);
+ getHealth()
+ .then(setHealth)
+ .catch((e) => setError(e.message))
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ refresh();
+ const t = setInterval(refresh, 15_000);
+ return () => clearInterval(t);
+ }, []);
+
+ return (
+
+
+
+
Health
+
+ {health && (
+
+ {health.ok ? 'All systems operational' : 'Degraded'}
+
+ )}
+
+ Refresh
+
+
+
+
+ {error &&
{error}
}
+ {loading && !health &&
Checking…
}
+
+ {health && (
+ <>
+
+ {health.services.map((svc) => (
+
+
{svc.name}
+
{svc.status}
+ {svc.latencyMs > 0 && (
+
{svc.latencyMs}ms
+ )}
+
+ ))}
+
+
Last checked: {health.checkedAt} · auto-refreshes every 15s
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
new file mode 100644
index 0000000..e397ff2
--- /dev/null
+++ b/apps/admin/src/app/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: 'oO Admin',
+ description: 'oO admin console',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx
new file mode 100644
index 0000000..6c4afaa
--- /dev/null
+++ b/apps/admin/src/app/login/page.tsx
@@ -0,0 +1,16 @@
+export default function LoginPage() {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/app/models/page.tsx b/apps/admin/src/app/models/page.tsx
new file mode 100644
index 0000000..8301fdc
--- /dev/null
+++ b/apps/admin/src/app/models/page.tsx
@@ -0,0 +1,30 @@
+import { AdminShell } from '@/components/AdminShell';
+
+export default function ModelsPage() {
+ const mlflowUrl = process.env.NEXT_PUBLIC_MLFLOW_URL ?? 'http://localhost:5000';
+
+ return (
+
+
+
+
+ MLflow is embedded below when running under the full compose profile.
+ Promote or archive model versions via the MLflow UI; each action writes to the audit log automatically.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/ops/page.tsx b/apps/admin/src/app/ops/page.tsx
new file mode 100644
index 0000000..f9312c7
--- /dev/null
+++ b/apps/admin/src/app/ops/page.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { getPolicies, togglePolicy, replaySignal, PolicyInfo } from '@/lib/api';
+
+const VALID_SUBJECTS = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
+
+export default function OpsPage() {
+ const [policies, setPolicies] = useState([]);
+ const [replaySubject, setReplaySubject] = useState(VALID_SUBJECTS[0]);
+ const [replayPayload, setReplayPayload] = useState('{\n "userId": "",\n "tipId": ""\n}');
+ const [msg, setMsg] = useState('');
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ getPolicies().then((r) => setPolicies(r.policies)).catch(() => {});
+ }, []);
+
+ const handleToggle = async (name: string, active: boolean) => {
+ try {
+ await togglePolicy(name, active);
+ setPolicies((prev) => prev.map((p) => p.name === name ? { ...p, active } : p));
+ setMsg(`Policy "${name}" ${active ? 'enabled' : 'disabled'}.`);
+ } catch (e: any) {
+ setError(e.message);
+ }
+ };
+
+ const handleReplay = async () => {
+ let payload: Record;
+ try {
+ payload = JSON.parse(replayPayload);
+ } catch {
+ setError('Invalid JSON payload');
+ return;
+ }
+ try {
+ await replaySignal(replaySubject, payload);
+ setMsg(`Signal replayed: ${replaySubject}`);
+ setError('');
+ } catch (e: any) {
+ setError(e.message);
+ }
+ };
+
+ return (
+
+
+
Ops actions
+ {msg &&
{msg}
}
+ {error &&
{error}
}
+
+ {/* Policy toggles */}
+
+ Policies
+ {policies.length === 0 ? (
+ No shadow policies registered. Shadow policies can be added to the recommender source.
+ ) : (
+
+ {policies.map((p) => (
+
+ {p.name}
+ handleToggle(p.name, !p.active)}
+ className={`px-3 py-1 rounded text-xs ${p.active ? 'bg-green-800 text-green-200' : 'bg-gray-800 text-gray-400'}`}
+ >
+ {p.active ? 'Active' : 'Disabled'}
+
+
+ ))}
+
+ )}
+
+
+ {/* Replay signal */}
+
+ Replay signal
+ Re-emit a past event on the in-process bus. Useful for backfill and testing.
+
+ setReplaySubject(e.target.value)}
+ className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-full max-w-sm"
+ >
+ {VALID_SUBJECTS.map((s) => {s} )}
+
+
+
+
+ {/* User-level ops */}
+
+ User-level actions
+
+ Revoke integration tokens and reset bandit state are available on the{' '}
+ Users page — navigate to a user detail view.
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
new file mode 100644
index 0000000..371e439
--- /dev/null
+++ b/apps/admin/src/app/page.tsx
@@ -0,0 +1,12 @@
+import { AdminShell } from '@/components/AdminShell';
+import { OverviewDashboard } from '@/components/OverviewDashboard';
+
+export const dynamic = 'force-dynamic';
+
+export default function OverviewPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/reward-analytics/page.tsx b/apps/admin/src/app/reward-analytics/page.tsx
new file mode 100644
index 0000000..1e240ed
--- /dev/null
+++ b/apps/admin/src/app/reward-analytics/page.tsx
@@ -0,0 +1,144 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { getRewardAnalytics } from '@/lib/api';
+
+const ACTION_COLORS: Record = {
+ done: 'bg-green-500',
+ helpful: 'bg-teal-500',
+ snooze: 'bg-yellow-500',
+ not_helpful: 'bg-orange-500',
+ dismiss: 'bg-red-500',
+};
+
+export default function RewardAnalyticsPage() {
+ const [days, setDays] = useState(30);
+ const [data, setData] = useState> | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ getRewardAnalytics(days)
+ .then(setData)
+ .catch((e) => setError(e.message))
+ .finally(() => setLoading(false));
+ }, [days]);
+
+ // Aggregate totals per action across all days
+ const totals: Record = {};
+ for (const row of data?.daily ?? []) {
+ totals[row.action] = (totals[row.action] ?? 0) + Number(row.count);
+ }
+ const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
+
+ // Aggregate per policy
+ const policyMap: Record> = {};
+ for (const row of data?.byPolicy ?? []) {
+ if (!row.policy) continue;
+ policyMap[row.policy] ??= {};
+ if (row.action) policyMap[row.policy][row.action] = (policyMap[row.policy][row.action] ?? 0) + Number(row.count);
+ }
+
+ return (
+
+
+
+
Reward analytics
+ setDays(Number(e.target.value))} className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-300">
+ Last 7 days
+ Last 30 days
+ Last 90 days
+
+
+
+ {error &&
{error}
}
+ {loading &&
Loading…
}
+
+ {/* Reaction breakdown bar */}
+ {grandTotal > 0 && (
+
+
Reaction distribution ({grandTotal} total)
+
+ {Object.entries(totals).map(([action, count]) => (
+
+ ))}
+
+
+ {Object.entries(totals).map(([action, count]) => (
+
+
+ {action}: {count} ({((count / grandTotal) * 100).toFixed(1)}%)
+
+ ))}
+
+
+ )}
+
+ {/* Per-policy table */}
+ {Object.keys(policyMap).length > 0 && (
+
+
Per-policy reactions
+
+
+
+ Policy
+ {['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
+ {a}
+ ))}
+
+
+
+ {Object.entries(policyMap).map(([policy, actions]) => (
+
+ {policy}
+ {['done', 'helpful', 'snooze', 'not_helpful', 'dismiss'].map((a) => (
+ {actions[a] ?? 0}
+ ))}
+
+ ))}
+
+
+
+ )}
+
+ {/* Daily table */}
+ {(data?.daily?.length ?? 0) > 0 && (
+
+
Daily breakdown
+
+
+
+
+ Date
+ Action
+ Count
+
+
+
+ {data!.daily.map((row, i) => (
+
+ {row.date}
+ {row.action}
+ {row.count}
+
+ ))}
+
+
+
+
+ )}
+
+ {!loading && grandTotal === 0 && (
+
No reaction data in this period.
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/sql/page.tsx b/apps/admin/src/app/sql/page.tsx
new file mode 100644
index 0000000..d3b82c8
--- /dev/null
+++ b/apps/admin/src/app/sql/page.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { runSql, getSavedQueries, saveQuery, deleteSavedQuery, SavedQuery } from '@/lib/api';
+
+const EXAMPLE_QUERIES = [
+ 'SELECT * FROM users ORDER BY created_at DESC LIMIT 20',
+ 'SELECT action, count(*) as cnt FROM tip_feedback GROUP BY action',
+ 'SELECT policy, count(*) as cnt FROM tip_scores GROUP BY policy',
+ 'SELECT date(served_at) as day, count(*) as tips FROM tip_views GROUP BY day ORDER BY day DESC LIMIT 14',
+];
+
+export default function SqlPage() {
+ const [query, setQuery] = useState(EXAMPLE_QUERIES[0]);
+ const [rows, setRows] = useState([]);
+ const [cols, setCols] = useState([]);
+ const [rowCount, setRowCount] = useState(null);
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState('');
+ const [savedQueries, setSavedQueries] = useState([]);
+ const [saveName, setSaveName] = useState('');
+
+ useEffect(() => {
+ getSavedQueries().then((r) => setSavedQueries(r.queries)).catch(() => {});
+ }, []);
+
+ const run = async () => {
+ if (!query.trim()) return;
+ setRunning(true);
+ setError('');
+ try {
+ const res = await runSql(query);
+ const r = res.rows as Record[];
+ setRows(r);
+ setRowCount(res.rowCount);
+ setCols(r.length > 0 ? Object.keys(r[0] as object) : []);
+ } catch (e: any) {
+ setError(e.message);
+ setRows([]);
+ setRowCount(null);
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!saveName.trim()) return;
+ await saveQuery(saveName, query);
+ const res = await getSavedQueries();
+ setSavedQueries(res.queries);
+ setSaveName('');
+ };
+
+ const handleDelete = async (id: string) => {
+ await deleteSavedQuery(id);
+ setSavedQueries((prev) => prev.filter((q) => q.id !== id));
+ };
+
+ return (
+
+
+
+
SQL runner
+ Read-only · SELECT only · sunsets in M4
+
+
+
+ {/* Editor */}
+
+
+ {/* Saved / examples */}
+
+
Saved queries
+ {savedQueries.length === 0 && (
+
None saved yet
+ )}
+ {savedQueries.map((q) => (
+
+ setQuery(q.sql)} className="text-xs text-indigo-400 hover:text-indigo-300 text-left">{q.name}
+ handleDelete(q.id)} className="text-xs text-gray-600 hover:text-red-400">✕
+
+ ))}
+
Examples
+ {EXAMPLE_QUERIES.map((q, i) => (
+
setQuery(q)} className="block text-xs text-gray-500 hover:text-gray-300 text-left truncate w-full">
+ {q.slice(0, 40)}…
+
+ ))}
+
+
+
+ {error &&
{error}
}
+
+ {rowCount !== null && (
+
{rowCount} rows returned
+ )}
+
+ {cols.length > 0 && (
+
+
+
+
+ {cols.map((c) => (
+ {c}
+ ))}
+
+
+
+ {(rows as Record[]).map((row, i) => (
+
+ {cols.map((c) => (
+ {String(row[c] ?? '')}
+ ))}
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/tips/page.tsx b/apps/admin/src/app/tips/page.tsx
new file mode 100644
index 0000000..403b450
--- /dev/null
+++ b/apps/admin/src/app/tips/page.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AdminShell } from '@/components/AdminShell';
+import { getTips, TipScore } from '@/lib/api';
+
+export default function TipsPage() {
+ const [tips, setTips] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [userId, setUserId] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const LIMIT = 50;
+
+ const fetch_ = async (off = 0) => {
+ setLoading(true);
+ try {
+ const res = await getTips({ limit: LIMIT, offset: off, userId: userId || undefined });
+ setTips(res.tips);
+ setTotal(res.total);
+ setOffset(off);
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { fetch_(0); }, [userId]);
+
+ return (
+
+
+
+
Recommendation log
+ {total} total
+
+
+
+ setUserId(e.target.value)}
+ placeholder="Filter by user ID"
+ className="bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 w-72"
+ />
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ Served at
+ User
+ Policy
+ Score
+ Candidates
+ Latency
+ Features
+
+
+
+ {tips.map((t) => {
+ const feats = t.featuresJson ? JSON.parse(t.featuresJson) : null;
+ return (
+
+ {t.servedAt.slice(0, 19)}
+ {t.userId.slice(0, 8)}…
+
+
+ {t.policy}
+
+
+ {t.mlScore != null ? (t.mlScore / 1000).toFixed(3) : '—'}
+ {t.candidateCount ?? '—'}
+ {t.latencyMs != null ? `${t.latencyMs}ms` : '—'}
+
+ {feats ? `p${feats.priority} ${feats.is_overdue ? '⚠' : ''}` : '—'}
+
+
+ );
+ })}
+
+
+
+
+
+ fetch_(offset - LIMIT)} disabled={offset === 0 || loading} className="text-gray-400 hover:text-white disabled:opacity-30">← Prev
+ {offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
+ fetch_(offset + LIMIT)} disabled={offset + LIMIT >= total || loading} className="text-gray-400 hover:text-white disabled:opacity-30">Next →
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/users/[id]/page.tsx b/apps/admin/src/app/users/[id]/page.tsx
new file mode 100644
index 0000000..ae4eed0
--- /dev/null
+++ b/apps/admin/src/app/users/[id]/page.tsx
@@ -0,0 +1,12 @@
+import { AdminShell } from '@/components/AdminShell';
+import { UserDetail } from '@/components/UserDetail';
+
+export const dynamic = 'force-dynamic';
+
+export default function UserDetailPage({ params }: { params: { id: string } }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/users/page.tsx b/apps/admin/src/app/users/page.tsx
new file mode 100644
index 0000000..29741d8
--- /dev/null
+++ b/apps/admin/src/app/users/page.tsx
@@ -0,0 +1,12 @@
+import { AdminShell } from '@/components/AdminShell';
+import { UsersTable } from '@/components/UsersTable';
+
+export const dynamic = 'force-dynamic';
+
+export default function UsersPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/AdminShell.tsx b/apps/admin/src/components/AdminShell.tsx
new file mode 100644
index 0000000..a24c5a9
--- /dev/null
+++ b/apps/admin/src/components/AdminShell.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+const NAV = [
+ { href: '/', label: 'Overview' },
+ { href: '/users', label: 'Users' },
+ { href: '/events', label: 'Events' },
+ { href: '/features', label: 'Features' },
+ { href: '/tips', label: 'Rec log' },
+ { href: '/reward-analytics', label: 'Rewards' },
+ { href: '/experiments', label: 'Experiments' },
+ { href: '/models', label: 'Models' },
+ { href: '/data-quality', label: 'Data quality' },
+ { href: '/ops', label: 'Ops' },
+ { href: '/sql', label: 'SQL runner' },
+ { href: '/health', label: 'Health' },
+ { href: '/audit', label: 'Audit log' },
+ { href: '/docs', label: 'Docs' },
+];
+
+export function AdminShell({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ return (
+
+ {/* Sidebar */}
+
+
+ oO
+
+ Admin
+
+
+
+ {NAV.map(({ href, label }) => {
+ const active = href === '/' ? pathname === '/' : pathname.startsWith(href);
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+ {/* Main content */}
+
{children}
+
+ );
+}
diff --git a/apps/admin/src/components/AuditLog.tsx b/apps/admin/src/components/AuditLog.tsx
new file mode 100644
index 0000000..86118a0
--- /dev/null
+++ b/apps/admin/src/components/AuditLog.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { getAuditLog, type AuditAction } from '@/lib/api';
+
+const PAGE_SIZE = 50;
+
+export function AuditLog() {
+ const [rows, setRows] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ getAuditLog(PAGE_SIZE, offset)
+ .then(({ actions, total }) => {
+ setRows(actions);
+ setTotal(total);
+ })
+ .catch((e) => setError(String(e.message)))
+ .finally(() => setLoading(false));
+ }, [offset]);
+
+ if (error) return Error: {error}
;
+
+ return (
+
+
+
Audit log
+ {total} entries
+
+
+
+
+
+
+ {['Time', 'Admin', 'Action', 'Target'].map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {loading ? (
+
+
+ Loading…
+
+
+ ) : rows.length === 0 ? (
+
+
+ No actions logged yet.
+
+
+ ) : (
+ rows.map((a) => (
+
+
+ {a.createdAt.slice(0, 19).replace('T', ' ')}
+
+
+ {a.adminId.slice(0, 8)}…
+
+
+
+ {a.action}
+
+
+
+ {a.targetType && (
+ {a.targetType}:
+ )}
+ {a.targetId?.slice(0, 12) ?? '—'}
+
+
+ ))
+ )}
+
+
+
+
+ {total > PAGE_SIZE && (
+
+ setOffset(Math.max(0, offset - PAGE_SIZE))}
+ className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
+ >
+ Previous
+
+
+ {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
+
+ = total}
+ onClick={() => setOffset(offset + PAGE_SIZE)}
+ className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
+ >
+ Next
+
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/OverviewDashboard.tsx b/apps/admin/src/components/OverviewDashboard.tsx
new file mode 100644
index 0000000..3640b66
--- /dev/null
+++ b/apps/admin/src/components/OverviewDashboard.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { getStats, type AdminStats } from '@/lib/api';
+
+function KpiCard({
+ title,
+ value,
+ sub,
+}: {
+ title: string;
+ value: string | number;
+ sub?: string;
+}) {
+ return (
+
+
{title}
+
{value}
+ {sub &&
{sub}
}
+
+ );
+}
+
+function ReactionBar({ reactions }: { reactions: Record }) {
+ const total = Object.values(reactions).reduce((a, b) => a + b, 0);
+ if (total === 0) return No reactions yet.
;
+
+ const COLORS: Record = {
+ done: 'bg-emerald-500',
+ snooze: 'bg-yellow-400',
+ dismiss: 'bg-red-500',
+ };
+
+ return (
+
+ {Object.entries(reactions).map(([action, count]) => (
+
+ ))}
+
+ );
+}
+
+export function OverviewDashboard() {
+ const [stats, setStats] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ getStats()
+ .then(setStats)
+ .catch((e) => setError(String(e.message)));
+ }, []);
+
+ if (error) {
+ return Failed to load stats: {error}
;
+ }
+
+ const activationPct =
+ stats && stats.totalUsers > 0
+ ? ((stats.activatedUsers / stats.totalUsers) * 100).toFixed(1)
+ : null;
+
+ return (
+
+
+
Overview
+
Last 7 days unless noted
+
+
+ {/* KPI grid */}
+
+
+
+
+
+
+
+ {/* Reactions */}
+
+
+ Reactions last 7 days
+
+ {stats ? (
+
+ ) : (
+
Loading…
+ )}
+
+
+ {/* Activation funnel */}
+
+
+ Activation funnel
+
+ {stats ? (
+
+
+
+ a + b, 0)}
+ max={stats.tipsServedLast7d}
+ dimMax
+ />
+
+ ) : (
+
Loading…
+ )}
+
+
+ );
+}
+
+function FunnelRow({
+ label,
+ value,
+ max,
+ dimMax,
+}: {
+ label: string;
+ value: number;
+ max: number;
+ dimMax?: boolean;
+}) {
+ const pct = max > 0 ? (value / max) * 100 : 0;
+ return (
+
+
{label}
+
+
{value}
+ {!dimMax && max > 0 && (
+
{pct.toFixed(0)}%
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/UserDetail.tsx b/apps/admin/src/components/UserDetail.tsx
new file mode 100644
index 0000000..eedeba1
--- /dev/null
+++ b/apps/admin/src/components/UserDetail.tsx
@@ -0,0 +1,159 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } from '@/lib/api';
+
+export function UserDetail({ userId }: { userId: string }) {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(null); // which action is running
+
+ useEffect(() => {
+ getUserDetail(userId)
+ .then(setData)
+ .catch((e) => setError(String(e.message)));
+ }, [userId]);
+
+ async function handleRevoke(provider: string) {
+ if (!confirm(`Revoke ${provider} for this user?`)) return;
+ setBusy(`revoke:${provider}`);
+ try {
+ await revokeIntegration(userId, provider);
+ setData((d) =>
+ d
+ ? { ...d, integrations: d.integrations.filter((i) => i.provider !== provider) }
+ : d,
+ );
+ } catch (e: unknown) {
+ alert(`Failed: ${(e as Error).message}`);
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function handleResetBandit() {
+ if (!confirm("Reset this user's LinUCB model? Their personalization will start over.")) return;
+ setBusy('bandit');
+ try {
+ await resetBandit(userId);
+ alert('Bandit reset.');
+ } catch (e: unknown) {
+ alert(`Failed: ${(e as Error).message}`);
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ if (error) return Error: {error}
;
+ if (!data) return Loading…
;
+
+ const { user, integrations, tipsServed, lastTipAt, recentFeedback } = data;
+
+ return (
+
+ {/* Header */}
+
+
+
{user.name ?? user.email}
+
{user.email}
+
+
+
+ {busy === 'bandit' ? 'Resetting…' : 'Reset bandit'}
+
+
+
+
+ {/* Identity */}
+
+
+
+
+
+ {user.deletedAt &&
}
+
+
+ {/* Integrations */}
+
+ {integrations.length === 0 ? (
+ No integrations connected.
+ ) : (
+ integrations.map((i) => (
+
+
+ {i.provider}
+
+ connected {i.connectedAt.slice(0, 10)}
+
+
+
handleRevoke(i.provider)}
+ disabled={busy === `revoke:${i.provider}`}
+ className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-40"
+ >
+ {busy === `revoke:${i.provider}` ? 'Revoking…' : 'Revoke'}
+
+
+ ))
+ )}
+
+
+ {/* Tip stats */}
+
+
+ {/* Feedback history */}
+
+ {recentFeedback.length === 0 ? (
+ No feedback recorded.
+ ) : (
+
+ {recentFeedback.map((f) => (
+
+
+ {f.action}
+
+
+ {f.createdAt.slice(0, 19).replace('T', ' ')}
+
+ {f.tipId}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/apps/admin/src/components/UsersTable.tsx b/apps/admin/src/components/UsersTable.tsx
new file mode 100644
index 0000000..79d7e5b
--- /dev/null
+++ b/apps/admin/src/components/UsersTable.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { getUsers, type AdminUser } from '@/lib/api';
+
+const PAGE_SIZE = 50;
+
+export function UsersTable() {
+ const [users, setUsers] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ getUsers(PAGE_SIZE, offset)
+ .then(({ users, total }) => {
+ setUsers(users);
+ setTotal(total);
+ })
+ .catch((e) => setError(String(e.message)))
+ .finally(() => setLoading(false));
+ }, [offset]);
+
+ if (error) return Error: {error}
;
+
+ return (
+
+
+
Users
+ {total} total
+
+
+
+
+
+
+ {['Email', 'Name', 'Role', 'Consent', 'Joined', 'Status'].map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {loading ? (
+
+
+ Loading…
+
+
+ ) : users.length === 0 ? (
+
+
+ No users yet.
+
+
+ ) : (
+ users.map((u) => (
+
+
+
+ {u.email}
+
+
+ {u.name ?? '—'}
+
+
+ {u.role}
+
+
+
+ {u.consentGiven ? (
+ yes
+ ) : (
+ no
+ )}
+
+
+ {u.createdAt.slice(0, 10)}
+
+
+ {u.deletedAt ? (
+ deleted
+ ) : (
+ active
+ )}
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+ {total > PAGE_SIZE && (
+
+ setOffset(Math.max(0, offset - PAGE_SIZE))}
+ className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
+ >
+ Previous
+
+
+ {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
+
+ = total}
+ onClick={() => setOffset(offset + PAGE_SIZE)}
+ className="px-3 py-1.5 rounded border border-gray-700 disabled:opacity-30 hover:border-gray-500 transition-colors"
+ >
+ Next
+
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts
new file mode 100644
index 0000000..58c1479
--- /dev/null
+++ b/apps/admin/src/lib/api.ts
@@ -0,0 +1,222 @@
+const API = '/api';
+
+async function apiFetch(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${API}${path}`, {
+ credentials: 'include',
+ ...init,
+ headers: { 'Content-Type': 'application/json', ...init?.headers },
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw Object.assign(new Error(err.error ?? 'API error'), { status: res.status });
+ }
+ return res.json() as T;
+}
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface AdminStats {
+ dau: number;
+ wau: number;
+ tipsServedLast7d: number;
+ reactionsLast7d: Record;
+ totalUsers: number;
+ activatedUsers: number;
+}
+
+export interface AdminUser {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: string;
+ consentGiven: boolean;
+ consentAt: string | null;
+ createdAt: string;
+ deletedAt: string | null;
+}
+
+export interface AdminUserDetail {
+ user: AdminUser;
+ integrations: { provider: string; connectedAt: string }[];
+ tipsServed: number;
+ lastTipAt: string | null;
+ recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
+}
+
+export interface AuditAction {
+ id: string;
+ adminId: string;
+ action: string;
+ targetType: string | null;
+ targetId: string | null;
+ detail: string | null;
+ createdAt: string;
+}
+
+export interface StoredEvent {
+ id: number;
+ subject: string;
+ payload: unknown;
+ ts: string;
+}
+
+export interface TipScore {
+ id: string;
+ userId: string;
+ tipId: string;
+ policy: string;
+ mlScore: number | null;
+ featuresJson: string | null;
+ candidateCount: number | null;
+ latencyMs: number | null;
+ servedAt: string;
+}
+
+export interface HealthStatus {
+ ok: boolean;
+ checkedAt: string;
+ services: { name: string; status: string; latencyMs: number }[];
+}
+
+export interface PolicyInfo {
+ name: string;
+ active: boolean;
+}
+
+export interface SavedQuery {
+ id: string;
+ name: string;
+ sql: string;
+ createdAt: string;
+}
+
+export interface BanditStats {
+ user_id: string;
+ pulls: number;
+ reward_count: number;
+ cumulative_reward: number;
+ estimated_mean_reward: number;
+ theta: number[];
+ last_updated: string | null;
+}
+
+export interface FeatureHistory {
+ user_id: string;
+ history: { ts: string; features: Record; score: number; tip_id: string }[];
+}
+
+// ── Fetchers ───────────────────────────────────────────────────────────────
+
+export function getStats() {
+ return apiFetch('/admin/stats');
+}
+
+export function getUsers(limit = 50, offset = 0) {
+ return apiFetch<{ users: AdminUser[]; total: number }>(
+ `/admin/users?limit=${limit}&offset=${offset}`,
+ );
+}
+
+export function getUserDetail(id: string) {
+ return apiFetch(`/admin/users/${id}`);
+}
+
+export function revokeIntegration(userId: string, provider: string) {
+ return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/revoke-integration`, {
+ method: 'POST',
+ body: JSON.stringify({ provider }),
+ });
+}
+
+export function resetBandit(userId: string) {
+ return apiFetch<{ ok: boolean }>(`/admin/users/${userId}/reset-bandit`, {
+ method: 'POST',
+ });
+}
+
+export function getAuditLog(limit = 50, offset = 0) {
+ return apiFetch<{ actions: AuditAction[]; total: number }>(
+ `/admin/audit?limit=${limit}&offset=${offset}`,
+ );
+}
+
+export function getEvents(params: { subject?: string; userId?: string; limit?: number; since?: number } = {}) {
+ const q = new URLSearchParams();
+ if (params.subject) q.set('subject', params.subject);
+ if (params.userId) q.set('userId', params.userId);
+ if (params.limit) q.set('limit', String(params.limit));
+ if (params.since) q.set('since', String(params.since));
+ return apiFetch<{ events: StoredEvent[]; nextSince: number }>(`/admin/events?${q}`);
+}
+
+export function getTips(params: { limit?: number; offset?: number; userId?: string } = {}) {
+ const q = new URLSearchParams();
+ if (params.limit) q.set('limit', String(params.limit));
+ if (params.offset) q.set('offset', String(params.offset));
+ if (params.userId) q.set('userId', params.userId);
+ return apiFetch<{ tips: TipScore[]; total: number }>(`/admin/tips?${q}`);
+}
+
+export function getRewardAnalytics(days = 30) {
+ return apiFetch<{
+ daily: { date: string; action: string; count: number }[];
+ byPolicy: { policy: string; action: string; count: number }[];
+ byHour: { action: string; count: number; avgHour: number }[];
+ }>(`/admin/reward-analytics?days=${days}`);
+}
+
+export function getDataQuality() {
+ return apiFetch<{
+ scoringCallsLast30d: number;
+ missingFeatureRate: number;
+ staleTokenRate: number;
+ totalTokens: number;
+ staleTokens: number;
+ dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
+ }>('/admin/data-quality');
+}
+
+export function getHealth() {
+ return apiFetch('/admin/health');
+}
+
+export function getPolicies() {
+ return apiFetch<{ policies: PolicyInfo[] }>('/admin/policies');
+}
+
+export function togglePolicy(name: string, active: boolean) {
+ return apiFetch<{ ok: boolean }>(`/admin/policies/${name}/toggle`, {
+ method: 'POST',
+ body: JSON.stringify({ active }),
+ });
+}
+
+export function replaySignal(subject: string, payload: Record) {
+ return apiFetch<{ ok: boolean }>('/admin/replay-signal', {
+ method: 'POST',
+ body: JSON.stringify({ subject, payload }),
+ });
+}
+
+export function runSql(query: string) {
+ return apiFetch<{ rows: unknown[]; rowCount: number }>('/admin/sql', {
+ method: 'POST',
+ body: JSON.stringify({ query }),
+ });
+}
+
+export function getSavedQueries() {
+ return apiFetch<{ queries: SavedQuery[] }>('/admin/saved-queries');
+}
+
+export function saveQuery(name: string, querySql: string) {
+ return apiFetch<{ id: string }>('/admin/saved-queries', {
+ method: 'POST',
+ body: JSON.stringify({ name, querySql }),
+ });
+}
+
+export function deleteSavedQuery(id: string) {
+ return apiFetch<{ ok: boolean }>(`/admin/saved-queries/${id}`, { method: 'DELETE' });
+}
diff --git a/apps/admin/src/lib/docs.ts b/apps/admin/src/lib/docs.ts
new file mode 100644
index 0000000..5db1bc9
--- /dev/null
+++ b/apps/admin/src/lib/docs.ts
@@ -0,0 +1,119 @@
+/**
+ * Server-side utilities for reading project documentation from the monorepo
+ * `docs/` directory and rendering it as HTML.
+ *
+ * All functions are async and must only be called from server components or
+ * server actions (no 'use client' imports of this module).
+ *
+ * Directory layout relative to monorepo root:
+ * docs/adr/ — Architecture Decision Records (NNN-title.md)
+ * docs/architecture/ — longer architecture notes (topic.md)
+ */
+import { readdir, readFile } from 'fs/promises';
+import path from 'path';
+import { marked } from 'marked';
+
+// apps/admin sits two levels below the monorepo root.
+const DOCS_ROOT = path.resolve(process.cwd(), '../../docs');
+
+export type DocCategory = 'adr' | 'architecture';
+
+export interface DocMeta {
+ category: DocCategory;
+ slug: string; // filename without .md
+ title: string; // first H1 from the file, fallback = slug
+ href: string; // /docs/adr/0001-monorepo-polyglot
+ status?: string; // for ADRs: "Accepted", "Proposed", …
+ date?: string; // for ADRs: date after em-dash on Status line
+}
+
+export interface DocPage extends DocMeta {
+ html: string;
+}
+
+// ---------------------------------------------------------------------------
+// internal helpers
+// ---------------------------------------------------------------------------
+
+/** Extract the first # heading from markdown. */
+function extractTitle(md: string): string {
+ const m = md.match(/^#\s+(.+)$/m);
+ return m ? m[1].trim() : '';
+}
+
+/** Extract status + date from "## Status\nAccepted — 2026-04-13" pattern. */
+function extractStatus(md: string): { status?: string; date?: string } {
+ const block = md.match(/##\s+Status\s*\n+([^\n#]+)/);
+ if (!block) return {};
+ const line = block[1].trim();
+ // "Accepted — 2026-04-13" or "Proposed"
+ const parts = line.split(/\s*[–—]\s*/);
+ return { status: parts[0]?.trim(), date: parts[1]?.trim() };
+}
+
+function slugFromFile(filename: string): string {
+ return filename.replace(/\.md$/, '');
+}
+
+// ---------------------------------------------------------------------------
+// public API
+// ---------------------------------------------------------------------------
+
+/** List all docs in a category, sorted by filename. */
+export async function listDocs(category: DocCategory): Promise {
+ const dir = path.join(DOCS_ROOT, category);
+ let files: string[];
+ try {
+ files = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort();
+ } catch {
+ return [];
+ }
+
+ return Promise.all(
+ files.map(async (file) => {
+ const slug = slugFromFile(file);
+ const md = await readFile(path.join(dir, file), 'utf8');
+ const title = extractTitle(md) || slug;
+ const { status, date } = extractStatus(md);
+ return {
+ category,
+ slug,
+ title,
+ href: `/docs/${category}/${slug}`,
+ status,
+ date,
+ };
+ }),
+ );
+}
+
+/** List all docs across all categories. */
+export async function listAllDocs(): Promise> {
+ const [adr, architecture] = await Promise.all([listDocs('adr'), listDocs('architecture')]);
+ return { adr, architecture };
+}
+
+/** Read and render a single doc to HTML. */
+export async function getDoc(category: DocCategory, slug: string): Promise {
+ const file = path.join(DOCS_ROOT, category, `${slug}.md`);
+ let md: string;
+ try {
+ md = await readFile(file, 'utf8');
+ } catch {
+ return null;
+ }
+
+ const title = extractTitle(md) || slug;
+ const { status, date } = extractStatus(md);
+ const html = await marked(md, { gfm: true });
+
+ return {
+ category,
+ slug,
+ title,
+ href: `/docs/${category}/${slug}`,
+ status,
+ date,
+ html,
+ };
+}
diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts
new file mode 100644
index 0000000..129b8bd
--- /dev/null
+++ b/apps/admin/src/middleware.ts
@@ -0,0 +1,45 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export async function middleware(req: NextRequest) {
+ const { pathname } = req.nextUrl;
+
+ // Pass through the login page and API calls
+ if (pathname.startsWith('/login') || pathname.startsWith('/api/')) {
+ return NextResponse.next();
+ }
+
+ const sid = req.cookies.get('sid')?.value;
+ if (!sid) {
+ const url = req.nextUrl.clone();
+ url.pathname = '/login';
+ return NextResponse.redirect(url);
+ }
+
+ // Verify admin role via API. The API is same-origin in production (Caddy routes
+ // /api/* to the Express service), so we use the rewrite target in dev.
+ const apiBase = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3078';
+ try {
+ const profile = await fetch(`${apiBase}/api/user/me`, {
+ headers: { cookie: `sid=${sid}` },
+ next: { revalidate: 0 },
+ });
+ if (!profile.ok) throw new Error('not ok');
+ const data = (await profile.json()) as { role?: string };
+ if (data.role !== 'admin') {
+ const url = req.nextUrl.clone();
+ url.pathname = '/forbidden';
+ return NextResponse.redirect(url);
+ }
+ } catch {
+ const url = req.nextUrl.clone();
+ url.pathname = '/login';
+ return NextResponse.redirect(url);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
+};
diff --git a/apps/web/src/app/tip/page.tsx b/apps/web/src/app/tip/page.tsx
index 55e5aab..68e2a7b 100644
--- a/apps/web/src/app/tip/page.tsx
+++ b/apps/web/src/app/tip/page.tsx
@@ -86,12 +86,17 @@ export default function TipPage() {
} catch { setPushState('denied'); }
}, []);
- const react = async (action: 'done' | 'dismiss' | 'snooze') => {
+ const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
if (!tip) return;
- setVisible(false);
- setState('done');
+ const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
+ if (isNavigating) {
+ setVisible(false);
+ setState('done');
+ } else {
+ setState('tip');
+ }
await sendFeedback(tip.id, { action });
- setTimeout(() => loadTip(), 700);
+ if (isNavigating) setTimeout(() => loadTip(), 700);
};
const onPointerDown = () => {
@@ -269,6 +274,8 @@ export default function TipPage() {
)}
react('done')} primary />
+ react('helpful')} />
+ react('not_helpful')} />
react('snooze')} />
react('dismiss')} />
deque:
+ if user_id not in _feature_history:
+ _feature_history[user_id] = deque(maxlen=FEATURE_HISTORY_SIZE)
+ return _feature_history[user_id]
# ── Feature helpers ────────────────────────────────────────────────────────
@@ -54,20 +68,21 @@ def state_path(user_id: str) -> Path:
return STATE_DIR / f"{safe}.json"
-def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray]:
- """Returns (A, b). A is DxD, b is D-vector."""
+def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray, dict]:
+ """Returns (A, b, meta). A is DxD, b is D-vector."""
p = state_path(user_id)
if p.exists():
raw = json.loads(p.read_text())
A = np.array(raw["A"], dtype=np.float64)
b = np.array(raw["b"], dtype=np.float64)
- return A, b
- return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64)
+ meta = raw.get("meta", {})
+ return A, b, meta
+ return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64), {}
-def save_state(user_id: str, A: np.ndarray, b: np.ndarray) -> None:
+def save_state(user_id: str, A: np.ndarray, b: np.ndarray, meta: dict) -> None:
p = state_path(user_id)
- p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist()}))
+ p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist(), "meta": meta}))
# ── API models ─────────────────────────────────────────────────────────────
@@ -107,7 +122,7 @@ class ScoreResponse(BaseModel):
class RewardRequest(BaseModel):
user_id: str
tip_id: str
- reward: float # +1 done, 0 snooze, -1 dismiss
+ reward: float # +1 done, +0.5 helpful, 0 snooze, -0.5 not_helpful, -1 dismiss
features: CandidateFeatures
@@ -127,7 +142,7 @@ def score(req: ScoreRequest) -> ScoreResponse:
if not req.candidates:
raise HTTPException(status_code=422, detail="No candidates")
- A, b = load_state(req.user_id)
+ A, b, meta = load_state(req.user_id)
try:
A_inv = np.linalg.inv(A)
except np.linalg.LinAlgError:
@@ -137,6 +152,7 @@ def score(req: ScoreRequest) -> ScoreResponse:
best_id = None
best_score = -float("inf")
+ best_features: dict = {}
for candidate in req.candidates:
feat_dict = {
@@ -152,13 +168,28 @@ def score(req: ScoreRequest) -> ScoreResponse:
if ucb > best_score:
best_score = ucb
best_id = candidate.id
+ best_features = feat_dict
+
+ # Log to feature history ring buffer
+ history = get_feature_history(req.user_id)
+ history.append({
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "features": best_features,
+ "score": best_score,
+ "tip_id": best_id,
+ })
+
+ # Update meta stats
+ meta["pulls"] = meta.get("pulls", 0) + 1
+ meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ save_state(req.user_id, A, b, meta)
return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
@app.post("/reward", response_model=RewardResponse)
def reward(req: RewardRequest) -> RewardResponse:
- A, b = load_state(req.user_id)
+ A, b, meta = load_state(req.user_id)
feat_dict = {
"hour_of_day": req.features.hour_of_day,
"is_overdue": req.features.is_overdue,
@@ -168,5 +199,58 @@ def reward(req: RewardRequest) -> RewardResponse:
x = build_feature_vector(feat_dict)
A += np.outer(x, x)
b += req.reward * x
- save_state(req.user_id, A, b)
+
+ # Track cumulative reward in meta
+ meta["cumulative_reward"] = meta.get("cumulative_reward", 0.0) + req.reward
+ meta["reward_count"] = meta.get("reward_count", 0) + 1
+ meta["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+
+ save_state(req.user_id, A, b, meta)
return RewardResponse(ok=True)
+
+
+@app.post("/reset/{user_id}", response_model=RewardResponse)
+def reset(user_id: str) -> RewardResponse:
+ """Reset per-user bandit state (admin action)."""
+ p = state_path(user_id)
+ if p.exists():
+ p.unlink()
+ if user_id in _feature_history:
+ _feature_history[user_id].clear()
+ return RewardResponse(ok=True)
+
+
+@app.get("/stats/{user_id}")
+def stats(user_id: str):
+ """Return current LinUCB state summary for a user."""
+ A, b, meta = load_state(user_id)
+ try:
+ A_inv = np.linalg.inv(A)
+ theta = (A_inv @ b).tolist()
+ except np.linalg.LinAlgError:
+ theta = [0.0] * D
+
+ pulls = meta.get("pulls", 0)
+ cumulative_reward = meta.get("cumulative_reward", 0.0)
+ reward_count = meta.get("reward_count", 0)
+ estimated_mean = cumulative_reward / reward_count if reward_count > 0 else 0.0
+
+ return {
+ "user_id": user_id,
+ "pulls": pulls,
+ "reward_count": reward_count,
+ "cumulative_reward": cumulative_reward,
+ "estimated_mean_reward": estimated_mean,
+ "theta": theta,
+ "last_updated": meta.get("last_updated"),
+ }
+
+
+@app.get("/features/{user_id}")
+def features(user_id: str):
+ """Return recent feature vectors logged at scoring time."""
+ history = get_feature_history(user_id)
+ return {
+ "user_id": user_id,
+ "history": list(history),
+ }
diff --git a/packages/shared-types/src/http/tip.ts b/packages/shared-types/src/http/tip.ts
index dadfa9a..4dfb1fe 100644
--- a/packages/shared-types/src/http/tip.ts
+++ b/packages/shared-types/src/http/tip.ts
@@ -14,6 +14,6 @@ export interface RecommendResponse {
/** POST /tip/:id/feedback request body */
export interface TipFeedback {
- action: 'done' | 'dismiss' | 'snooze';
+ action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
snoozedUntil?: string; // ISO 8601, required when action = snooze
}
diff --git a/services/api/src/db/index.ts b/services/api/src/db/index.ts
index 674edf2..9f04763 100644
--- a/services/api/src/db/index.ts
+++ b/services/api/src/db/index.ts
@@ -8,6 +8,9 @@ sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
+// Raw sqlite client — used by the SQL runner endpoint.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const rawSqlite: any = sqlite;
export function runMigrations() {
sqlite.exec(`
@@ -17,6 +20,7 @@ export function runMigrations() {
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,
@@ -43,11 +47,72 @@ export function runMigrations() {
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
+ );
`);
+
+ // 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 ''`,
+ ]) {
+ 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);
+ }
}
diff --git a/services/api/src/db/schema.ts b/services/api/src/db/schema.ts
index 1f60721..c8d5c3b 100644
--- a/services/api/src/db/schema.ts
+++ b/services/api/src/db/schema.ts
@@ -6,6 +6,7 @@ export const users = sqliteTable('users', {
name: text('name'),
image: text('image'),
googleId: text('google_id').unique(),
+ role: text('role').notNull().default('user'), // 'user' | 'admin'
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
consentAt: text('consent_at'),
createdAt: text('created_at').notNull(),
@@ -54,3 +55,37 @@ export const sessions = sqliteTable('sessions', {
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(),
+});
+
+// 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(),
+});
diff --git a/services/api/src/events/bus.ts b/services/api/src/events/bus.ts
index a943f81..fd3a0b9 100644
--- a/services/api/src/events/bus.ts
+++ b/services/api/src/events/bus.ts
@@ -22,7 +22,7 @@ export type TipServedEvent = {
export type TipFeedbackEvent = {
userId: string;
tipId: string;
- action: 'done' | 'dismiss' | 'snooze';
+ action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
reward: number;
createdAt: string;
};
@@ -39,14 +39,56 @@ type EventMap = {
'signals.task.synced': TaskSyncedEvent;
};
+export type StoredEvent = {
+ id: number;
+ subject: string;
+ payload: unknown;
+ ts: string;
+};
+
+const RING_SIZE = 500;
+
class Bus extends EventEmitter {
+ private ring: StoredEvent[] = [];
+ private seq = 0;
+
publish(subject: K, payload: EventMap[K]): void {
+ const entry: StoredEvent = {
+ id: ++this.seq,
+ subject,
+ payload,
+ ts: new Date().toISOString(),
+ };
+ if (this.ring.length >= RING_SIZE) this.ring.shift();
+ this.ring.push(entry);
this.emit(subject, payload);
}
subscribe(subject: K, handler: (payload: EventMap[K]) => void): void {
this.on(subject, handler);
}
+
+ /**
+ * Return recent events from the ring buffer.
+ * @param subject optional filter (prefix match, e.g. "signals.tip")
+ * @param userId optional user ID filter
+ * @param limit max events to return (default 100)
+ * @param since only events with id > since
+ */
+ tail(opts: { subject?: string; userId?: string; limit?: number; since?: number } = {}): StoredEvent[] {
+ const { subject, userId, limit = 100, since = 0 } = opts;
+ let results = this.ring.filter((e) => {
+ if (e.id <= since) return false;
+ if (subject && !e.subject.startsWith(subject)) return false;
+ if (userId) {
+ const p = e.payload as Record;
+ if (p.userId !== userId) return false;
+ }
+ return true;
+ });
+ if (results.length > limit) results = results.slice(results.length - limit);
+ return results;
+ }
}
export const bus = new Bus();
diff --git a/services/api/src/index.ts b/services/api/src/index.ts
index 3830cfd..e1c51ad 100644
--- a/services/api/src/index.ts
+++ b/services/api/src/index.ts
@@ -10,8 +10,12 @@ import { integrationsRouter } from './routes/integrations.js';
import { recommenderRouter } from './routes/recommender.js';
import { userRouter } from './routes/user.js';
import { pushRouter } from './routes/push.js';
+import { adminRouter } from './routes/admin.js';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
+import { requireAuth } from './middleware/session.js';
+import { requireAdmin } from './middleware/admin.js';
+import type { Request, Response } from 'express';
await mkdir(dirname(config.DATABASE_PATH), { recursive: true });
runMigrations();
@@ -35,6 +39,27 @@ app.use('/api/integrations', integrationsRouter);
app.use('/api', recommenderRouter);
app.use('/api/user', userRouter);
app.use('/api/push', pushRouter);
+app.use('/api/admin', adminRouter);
+
+// Proxy ml/serving endpoints through the API (admin-only).
+// Allows admin UI to call /api/ml/stats/:userId, /api/ml/features/:userId
+// without needing direct access to the ml/serving port.
+app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
+ const mlUrl = config.ML_SERVING_URL;
+ const target = `${mlUrl}${req.path}`;
+ try {
+ const upstream = await fetch(target, {
+ method: req.method,
+ headers: { 'Content-Type': 'application/json' },
+ body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
+ signal: AbortSignal.timeout(5000),
+ });
+ const data = await upstream.json();
+ res.status(upstream.status).json(data);
+ } catch (e: any) {
+ res.status(502).json({ error: 'ml/serving unavailable', detail: e.message });
+ }
+});
app.listen(config.PORT, () => {
console.log(`oO API listening on http://localhost:${config.PORT}`);
diff --git a/services/api/src/routes/admin.ts b/services/api/src/routes/admin.ts
new file mode 100644
index 0000000..9bea687
--- /dev/null
+++ b/services/api/src/routes/admin.ts
@@ -0,0 +1,609 @@
+import { type Router as ExpressRouter, Router, Response } from 'express';
+import { db, rawSqlite } from '../db/index.js';
+import {
+ users,
+ integrationTokens,
+ tipViews,
+ tipFeedback,
+ adminActions,
+ tipScores,
+ savedQueries,
+} from '../db/schema.js';
+import { eq, desc, sql, gte, and, isNull, lt } from 'drizzle-orm';
+import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
+import { requireAdmin } from '../middleware/admin.js';
+import { nanoid } from 'nanoid';
+import { bus } from '../events/bus.js';
+import { config } from '../config.js';
+import { getShadowPolicies, setPolicyActive } from './recommender.js';
+
+const router: ExpressRouter = Router();
+router.use(requireAuth, requireAdmin);
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/stats
+// ---------------------------------------------------------------------------
+router.get('/stats', async (_req: AuthenticatedRequest, res: Response) => {
+ const now = new Date();
+ const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
+
+ const [dauRow] = await db
+ .select({ count: sql`count(distinct user_id)` })
+ .from(tipViews)
+ .where(gte(tipViews.servedAt, dayAgo));
+
+ const [wauRow] = await db
+ .select({ count: sql`count(distinct user_id)` })
+ .from(tipViews)
+ .where(gte(tipViews.servedAt, weekAgo));
+
+ const [tipsRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(tipViews)
+ .where(gte(tipViews.servedAt, weekAgo));
+
+ const reactionRows = await db
+ .select({ action: tipFeedback.action, count: sql`count(*)` })
+ .from(tipFeedback)
+ .where(gte(tipFeedback.createdAt, weekAgo))
+ .groupBy(tipFeedback.action);
+
+ const reactions: Record = {};
+ for (const row of reactionRows) reactions[row.action] = Number(row.count);
+
+ const [totalUsersRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(users)
+ .where(isNull(users.deletedAt));
+
+ const [activatedRow] = await db
+ .select({ count: sql`count(distinct user_id)` })
+ .from(tipViews);
+
+ res.json({
+ dau: Number(dauRow?.count ?? 0),
+ wau: Number(wauRow?.count ?? 0),
+ tipsServedLast7d: Number(tipsRow?.count ?? 0),
+ reactionsLast7d: reactions,
+ totalUsers: Number(totalUsersRow?.count ?? 0),
+ activatedUsers: Number(activatedRow?.count ?? 0),
+ });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/users?limit=50&offset=0
+// ---------------------------------------------------------------------------
+router.get('/users', async (req: AuthenticatedRequest, res: Response) => {
+ const limit = Math.min(Number(req.query.limit ?? 50), 200);
+ const offset = Number(req.query.offset ?? 0);
+
+ const rows = await db
+ .select({
+ id: users.id,
+ email: users.email,
+ name: users.name,
+ image: users.image,
+ role: users.role,
+ consentGiven: users.consentGiven,
+ createdAt: users.createdAt,
+ deletedAt: users.deletedAt,
+ })
+ .from(users)
+ .orderBy(desc(users.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ const [countRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(users);
+
+ res.json({ users: rows, total: Number(countRow?.count ?? 0) });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/users/:id
+// ---------------------------------------------------------------------------
+router.get('/users/:id', async (req: AuthenticatedRequest, res: Response) => {
+ const userId = req.params.id as string;
+ const [user] = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ if (!user) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ const integrations = await db
+ .select({ provider: integrationTokens.provider, connectedAt: integrationTokens.connectedAt })
+ .from(integrationTokens)
+ .where(eq(integrationTokens.userId, user.id));
+
+ const [tipStats] = await db
+ .select({ count: sql`count(*)` })
+ .from(tipViews)
+ .where(eq(tipViews.userId, user.id));
+
+ const recentFeedback = await db
+ .select()
+ .from(tipFeedback)
+ .where(eq(tipFeedback.userId, user.id))
+ .orderBy(desc(tipFeedback.createdAt))
+ .limit(20);
+
+ const [lastView] = await db
+ .select({ servedAt: tipViews.servedAt })
+ .from(tipViews)
+ .where(eq(tipViews.userId, user.id))
+ .orderBy(desc(tipViews.servedAt))
+ .limit(1);
+
+ res.json({
+ user: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ image: user.image,
+ role: user.role,
+ consentGiven: user.consentGiven,
+ consentAt: user.consentAt,
+ createdAt: user.createdAt,
+ deletedAt: user.deletedAt,
+ },
+ integrations,
+ tipsServed: Number(tipStats?.count ?? 0),
+ lastTipAt: lastView?.servedAt ?? null,
+ recentFeedback,
+ });
+});
+
+// ---------------------------------------------------------------------------
+// POST /api/admin/users/:id/revoke-integration
+// ---------------------------------------------------------------------------
+router.post(
+ '/users/:id/revoke-integration',
+ async (req: AuthenticatedRequest, res: Response) => {
+ const targetUserId = req.params.id as string;
+ const { provider } = req.body as { provider: string };
+ if (!provider) {
+ res.status(400).json({ error: 'provider required' });
+ return;
+ }
+
+ const [token] = await db
+ .select()
+ .from(integrationTokens)
+ .where(
+ and(
+ eq(integrationTokens.userId, targetUserId),
+ eq(integrationTokens.provider, provider),
+ ),
+ )
+ .limit(1);
+
+ if (!token) {
+ res.status(404).json({ error: 'Integration not found' });
+ return;
+ }
+
+ if (provider === 'todoist') {
+ await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token.accessToken}` },
+ }).catch(() => {});
+ }
+
+ await db
+ .delete(integrationTokens)
+ .where(
+ and(
+ eq(integrationTokens.userId, targetUserId),
+ eq(integrationTokens.provider, provider),
+ ),
+ );
+
+ await db.insert(adminActions).values({
+ id: nanoid(),
+ adminId: req.userId!,
+ action: 'revoke_integration',
+ targetType: 'integration',
+ targetId: token.id,
+ detail: JSON.stringify({ userId: targetUserId, provider }),
+ createdAt: new Date().toISOString(),
+ });
+
+ res.json({ ok: true });
+ },
+);
+
+// ---------------------------------------------------------------------------
+// POST /api/admin/users/:id/reset-bandit
+// ---------------------------------------------------------------------------
+router.post(
+ '/users/:id/reset-bandit',
+ async (req: AuthenticatedRequest, res: Response) => {
+ const targetUserId = req.params.id as string;
+ const mlUrl = process.env.ML_SERVING_URL ?? 'http://localhost:8000';
+ let mlOk = true;
+ let mlError = '';
+ try {
+ const mlRes = await fetch(`${mlUrl}/reset/${targetUserId}`, { method: 'POST' });
+ mlOk = mlRes.ok;
+ if (!mlOk) mlError = mlRes.statusText;
+ } catch (e) {
+ mlOk = false;
+ mlError = String(e);
+ }
+
+ if (!mlOk) {
+ res.status(502).json({ error: 'ml/serving reset failed', detail: mlError });
+ return;
+ }
+
+ await db.insert(adminActions).values({
+ id: nanoid(),
+ adminId: req.userId!,
+ action: 'reset_bandit',
+ targetType: 'user',
+ targetId: targetUserId,
+ detail: null,
+ createdAt: new Date().toISOString(),
+ });
+
+ res.json({ ok: true });
+ },
+);
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/audit?limit=50&offset=0
+// ---------------------------------------------------------------------------
+router.get('/audit', async (req: AuthenticatedRequest, res: Response) => {
+ const limit = Math.min(Number(req.query.limit ?? 50), 200);
+ const offset = Number(req.query.offset ?? 0);
+
+ const rows = await db
+ .select()
+ .from(adminActions)
+ .orderBy(desc(adminActions.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ const [countRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(adminActions);
+
+ res.json({ actions: rows, total: Number(countRow?.count ?? 0) });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/events?subject=signals.tip&userId=&limit=100&since=0
+// Returns recent events from the in-process ring buffer.
+// ---------------------------------------------------------------------------
+router.get('/events', async (req: AuthenticatedRequest, res: Response) => {
+ const subject = req.query.subject as string | undefined;
+ const userId = req.query.userId as string | undefined;
+ const limit = Math.min(Number(req.query.limit ?? 100), 500);
+ const since = Number(req.query.since ?? 0);
+
+ const events = bus.tail({ subject, userId, limit, since });
+ res.json({ events, nextSince: events.length > 0 ? events[events.length - 1].id : since });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/tips?limit=50&offset=0&userId=
+// Recommendation log — per-tip explainability.
+// ---------------------------------------------------------------------------
+router.get('/tips', async (req: AuthenticatedRequest, res: Response) => {
+ const limit = Math.min(Number(req.query.limit ?? 50), 200);
+ const offset = Number(req.query.offset ?? 0);
+ const userId = req.query.userId as string | undefined;
+
+ const conditions = userId ? [eq(tipScores.userId, userId)] : [];
+
+ const rows = await db
+ .select()
+ .from(tipScores)
+ .where(conditions.length ? and(...(conditions as [ReturnType])) : undefined)
+ .orderBy(desc(tipScores.servedAt))
+ .limit(limit)
+ .offset(offset);
+
+ const [countRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(tipScores)
+ .where(conditions.length ? and(...(conditions as [ReturnType])) : undefined);
+
+ res.json({ tips: rows, total: Number(countRow?.count ?? 0) });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/reward-analytics?days=30
+// Reaction distribution over time + per-policy compare.
+// ---------------------------------------------------------------------------
+router.get('/reward-analytics', async (req: AuthenticatedRequest, res: Response) => {
+ const days = Math.min(Number(req.query.days ?? 30), 90);
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
+
+ // Daily reaction counts
+ const dailyRows = await db
+ .select({
+ date: sql`date(created_at)`,
+ action: tipFeedback.action,
+ count: sql`count(*)`,
+ })
+ .from(tipFeedback)
+ .where(gte(tipFeedback.createdAt, since))
+ .groupBy(sql`date(created_at)`, tipFeedback.action)
+ .orderBy(sql`date(created_at)`);
+
+ // Per-policy reward distribution (tip_scores joined with tip_feedback)
+ const policyRows = await db
+ .select({
+ policy: tipScores.policy,
+ action: tipFeedback.action,
+ count: sql`count(*)`,
+ })
+ .from(tipScores)
+ .leftJoin(tipFeedback, eq(tipScores.tipId, tipFeedback.tipId))
+ .where(gte(tipScores.servedAt, since))
+ .groupBy(tipScores.policy, tipFeedback.action);
+
+ // By hour_of_day (extracted from featuresJson)
+ const hourRows = await db
+ .select({
+ action: tipFeedback.action,
+ count: sql`count(*)`,
+ avgHour: sql`avg(json_extract(ts.features_json, '$.hour_of_day'))`,
+ })
+ .from(tipFeedback)
+ .leftJoin(tipScores, eq(tipFeedback.tipId, tipScores.tipId))
+ .where(gte(tipFeedback.createdAt, since))
+ .groupBy(tipFeedback.action);
+
+ res.json({
+ daily: dailyRows,
+ byPolicy: policyRows,
+ byHour: hourRows,
+ });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/data-quality
+// Missing-signal rates, stale token rates, feature NaN heatmap.
+// ---------------------------------------------------------------------------
+router.get('/data-quality', async (req: AuthenticatedRequest, res: Response) => {
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
+
+ // Total scoring calls in last 30d
+ const [totalRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(tipScores)
+ .where(gte(tipScores.servedAt, thirtyDaysAgo));
+ const total = Number(totalRow?.count ?? 0);
+
+ // Calls with no features (features_json IS NULL)
+ const [missingFeaturesRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(tipScores)
+ .where(and(gte(tipScores.servedAt, thirtyDaysAgo), isNull(tipScores.featuresJson)));
+ const missingFeatures = Number(missingFeaturesRow?.count ?? 0);
+
+ // Stale tokens: connected more than 7 days ago with no recent tip (proxy for stale)
+ const [staleTokensRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(integrationTokens)
+ .where(lt(integrationTokens.connectedAt, sevenDaysAgo));
+ const staleTokens = Number(staleTokensRow?.count ?? 0);
+
+ const [totalTokensRow] = await db
+ .select({ count: sql`count(*)` })
+ .from(integrationTokens);
+ const totalTokens = Number(totalTokensRow?.count ?? 0);
+
+ // Daily feature completeness (last 14 days)
+ const dailyQuality = await db
+ .select({
+ date: sql`date(served_at)`,
+ total: sql`count(*)`,
+ withFeatures: sql`sum(case when features_json is not null then 1 else 0 end)`,
+ avgCandidates: sql`avg(candidate_count)`,
+ })
+ .from(tipScores)
+ .where(gte(tipScores.servedAt, new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString()))
+ .groupBy(sql`date(served_at)`)
+ .orderBy(sql`date(served_at)`);
+
+ res.json({
+ scoringCallsLast30d: total,
+ missingFeatureRate: total > 0 ? missingFeatures / total : 0,
+ staleTokenRate: totalTokens > 0 ? staleTokens / totalTokens : 0,
+ totalTokens,
+ staleTokens,
+ dailyQuality,
+ });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/health
+// Fan-out to all subsystem /health endpoints.
+// ---------------------------------------------------------------------------
+router.get('/health', async (_req: AuthenticatedRequest, res: Response) => {
+ const checks: Array<{ name: string; url: string }> = [
+ { name: 'api', url: `http://localhost:${process.env.PORT ?? 3001}/health` },
+ { name: 'ml-serving', url: `${config.ML_SERVING_URL}/health` },
+ ];
+
+ const results = await Promise.allSettled(
+ checks.map(async ({ name, url }) => {
+ const t0 = Date.now();
+ try {
+ const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
+ return { name, status: r.ok ? 'ok' : 'degraded', latencyMs: Date.now() - t0 };
+ } catch {
+ return { name, status: 'down', latencyMs: Date.now() - t0 };
+ }
+ }),
+ );
+
+ // SQLite health
+ let dbStatus = 'ok';
+ try {
+ await db.select({ one: sql`1` }).from(users).limit(1);
+ } catch {
+ dbStatus = 'down';
+ }
+
+ // Event bus: always ok if process is alive
+ const eventBusStatus = 'ok';
+
+ const services = results.map((r) =>
+ r.status === 'fulfilled' ? r.value : { name: 'unknown', status: 'down', latencyMs: 0 },
+ );
+
+ services.push({ name: 'sqlite', status: dbStatus, latencyMs: 0 });
+ services.push({ name: 'event-bus', status: eventBusStatus, latencyMs: 0 });
+
+ const allOk = services.every((s) => s.status === 'ok');
+ res.json({ ok: allOk, services, checkedAt: new Date().toISOString() });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/policies
+// POST /api/admin/policies/:name/toggle
+// ---------------------------------------------------------------------------
+router.get('/policies', async (_req: AuthenticatedRequest, res: Response) => {
+ res.json({ policies: getShadowPolicies() });
+});
+
+router.post('/policies/:name/toggle', async (req: AuthenticatedRequest, res: Response) => {
+ const { name } = req.params as { name: string };
+ const { active } = req.body as { active: boolean };
+ const ok = setPolicyActive(name, active);
+ if (!ok) {
+ res.status(404).json({ error: 'Policy not found' });
+ return;
+ }
+
+ await db.insert(adminActions).values({
+ id: nanoid(),
+ adminId: req.userId!,
+ action: active ? 'enable_policy' : 'disable_policy',
+ targetType: 'policy',
+ targetId: name,
+ detail: null,
+ createdAt: new Date().toISOString(),
+ });
+
+ res.json({ ok: true });
+});
+
+// ---------------------------------------------------------------------------
+// POST /api/admin/replay-signal
+// Re-emit a past event on the bus (for testing / backfill).
+// Body: { subject: string, payload: object }
+// ---------------------------------------------------------------------------
+router.post('/replay-signal', async (req: AuthenticatedRequest, res: Response) => {
+ const { subject, payload } = req.body as { subject: string; payload: Record };
+ if (!subject || !payload) {
+ res.status(400).json({ error: 'subject and payload required' });
+ return;
+ }
+
+ const validSubjects = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
+ if (!validSubjects.includes(subject)) {
+ res.status(400).json({ error: 'unknown subject' });
+ return;
+ }
+
+ bus.publish(subject as 'signals.tip.served', payload as any);
+
+ await db.insert(adminActions).values({
+ id: nanoid(),
+ adminId: req.userId!,
+ action: 'replay_signal',
+ targetType: 'event',
+ targetId: null,
+ detail: JSON.stringify({ subject, payload }),
+ createdAt: new Date().toISOString(),
+ });
+
+ res.json({ ok: true });
+});
+
+// ---------------------------------------------------------------------------
+// POST /api/admin/sql
+// Read-only SQL runner. Only SELECT allowed.
+// ---------------------------------------------------------------------------
+router.post('/sql', async (req: AuthenticatedRequest, res: Response) => {
+ const { query } = req.body as { query: string };
+ if (!query || typeof query !== 'string') {
+ res.status(400).json({ error: 'query required' });
+ return;
+ }
+
+ const normalized = query.trim().toUpperCase();
+ if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) {
+ res.status(400).json({ error: 'Only SELECT queries are allowed' });
+ return;
+ }
+
+ // Block any mutation keywords (belt-and-suspenders)
+ const forbidden = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|PRAGMA|VACUUM)\b/i;
+ if (forbidden.test(query)) {
+ res.status(400).json({ error: 'Mutation statements are not allowed' });
+ return;
+ }
+
+ try {
+ const stmt = rawSqlite.prepare(query);
+ const rows = stmt.all();
+ res.json({ rows, rowCount: rows.length });
+ } catch (e: any) {
+ res.status(400).json({ error: e.message ?? 'Query error' });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/admin/saved-queries
+// POST /api/admin/saved-queries
+// DELETE /api/admin/saved-queries/:id
+// ---------------------------------------------------------------------------
+router.get('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
+ const rows = await db
+ .select()
+ .from(savedQueries)
+ .where(eq(savedQueries.adminId, req.userId!))
+ .orderBy(desc(savedQueries.createdAt));
+ res.json({ queries: rows });
+});
+
+router.post('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
+ const { name, querySql } = req.body as { name: string; querySql: string };
+ if (!name || !querySql) {
+ res.status(400).json({ error: 'name and querySql required' });
+ return;
+ }
+ const id = nanoid();
+ await db.insert(savedQueries).values({
+ id,
+ adminId: req.userId!,
+ name,
+ sql: querySql,
+ createdAt: new Date().toISOString(),
+ });
+ res.json({ id });
+});
+
+router.delete('/saved-queries/:id', async (req: AuthenticatedRequest, res: Response) => {
+ const { id } = req.params as { id: string };
+ await db
+ .delete(savedQueries)
+ .where(and(eq(savedQueries.id, id), eq(savedQueries.adminId, req.userId!)));
+ res.json({ ok: true });
+});
+
+export { router as adminRouter };
diff --git a/services/api/src/routes/recommender.ts b/services/api/src/routes/recommender.ts
index d14340f..4b9e982 100644
--- a/services/api/src/routes/recommender.ts
+++ b/services/api/src/routes/recommender.ts
@@ -1,7 +1,7 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import { nanoid } from 'nanoid';
import { db } from '../db/index.js';
-import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
+import { integrationTokens, tipFeedback, tipViews, tipScores } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js';
@@ -24,7 +24,31 @@ interface CachedTask extends Tip {
const taskCache = new Map();
-/** Parse a Todoist due date string into age in days (relative to now) */
+// ---------------------------------------------------------------------------
+// Shadow-policy registry
+// ---------------------------------------------------------------------------
+// A shadow policy runs alongside the active policy, logs its picks, but does
+// NOT affect what the user sees. Promotion to A/B or live is a manual step.
+// Structure: Map
+const shadowPolicies = new Map([
+ // Example: enable random as a shadow baseline
+ // ('random-shadow', { active: true }),
+]);
+
+export function getShadowPolicies() {
+ return Array.from(shadowPolicies.entries()).map(([name, s]) => ({ name, ...s }));
+}
+
+export function setPolicyActive(name: string, active: boolean): boolean {
+ if (!shadowPolicies.has(name)) return false;
+ shadowPolicies.set(name, { active });
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// Todoist helpers
+// ---------------------------------------------------------------------------
+
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
if (!due) return 0;
const dateStr = due.datetime ?? due.date;
@@ -71,11 +95,17 @@ async function fetchTodoistTasks(userId: string, accessToken: string): Promise {
+/** Call ml/serving for scored selection; returns { tip_id, score } or null on failure */
+async function remotePolicy(
+ userId: string,
+ tasks: CachedTask[],
+): Promise<{ tipId: string; score: number } | null> {
const hour = new Date().getHours();
const dayOfWeek = new Date().getDay();
@@ -99,8 +129,8 @@ async function remotePolicy(userId: string, tasks: CachedTask[]): Promise {
const [token] = await db
.select()
@@ -130,10 +162,15 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return;
}
+ const hour = new Date().getHours();
+ const dayOfWeek = new Date().getDay();
+ const t0 = Date.now();
+
// RemotePolicy with RandomPolicy fallback
- const scoredId = await remotePolicy(req.userId!, tasks);
- const tip = scoredId
- ? (tasks.find((t) => t.id === scoredId) ?? randomPolicy(tasks))
+ const scored = await remotePolicy(req.userId!, tasks);
+ const latencyMs = Date.now() - t0;
+ const tip = scored
+ ? (tasks.find((t) => t.id === scored.tipId) ?? randomPolicy(tasks))
: randomPolicy(tasks);
if (!tip) {
@@ -141,25 +178,63 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return;
}
+ const policy = scored ? 'linucb-v1' : 'random';
const servedAt = new Date().toISOString();
+
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
+ // Log recommendation explainability
+ await db.insert(tipScores).values({
+ id: nanoid(),
+ userId: req.userId!,
+ tipId: tip.id,
+ policy,
+ mlScore: scored ? Math.round(scored.score * 1000) : null,
+ featuresJson: JSON.stringify({
+ is_overdue: tip.features.is_overdue,
+ task_age_days: tip.features.task_age_days,
+ priority: tip.features.priority,
+ hour_of_day: hour,
+ day_of_week: dayOfWeek,
+ }),
+ candidateCount: tasks.length,
+ latencyMs,
+ servedAt,
+ });
+
bus.publish('signals.tip.served', {
userId: req.userId!,
tipId: tip.id,
- policy: scoredId ? 'linucb-v1' : 'random',
+ policy,
servedAt,
});
+ // Run shadow policies (fire-and-forget, no effect on user)
+ for (const [name, s] of shadowPolicies) {
+ if (!s.active) continue;
+ if (name.startsWith('random')) {
+ const shadowTip = randomPolicy(tasks);
+ bus.publish('signals.tip.served', {
+ userId: req.userId!,
+ tipId: shadowTip?.id ?? 'none',
+ policy: `shadow:${name}`,
+ servedAt,
+ });
+ }
+ }
+
res.json({ tip });
});
-/** POST /api/tip/:id/feedback */
+// ---------------------------------------------------------------------------
+// POST /api/tip/:id/feedback
+// ---------------------------------------------------------------------------
router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const { action } = req.body as { action: string };
const tipId = String(req.params.id);
- if (!['done', 'dismiss', 'snooze'].includes(action)) {
+ const validActions = ['done', 'dismiss', 'snooze', 'helpful', 'not_helpful'];
+ if (!validActions.includes(action)) {
res.status(400).json({ error: 'Invalid action' });
return;
}
@@ -173,18 +248,31 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
createdAt: new Date().toISOString(),
});
- // Capture task features before clearing cache
- const reward = action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0;
+ // Map action to reward (helpful/not_helpful supplement behavioural signals)
+ const rewardMap: Record = {
+ done: 1.0,
+ helpful: 0.5,
+ snooze: 0.0,
+ not_helpful: -0.5,
+ dismiss: -1.0,
+ };
+ const reward = rewardMap[action] ?? 0.0;
+
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
- taskCache.delete(req.userId!);
+
+ // Clear cache on behavioural actions (not on explicit helpful/not_helpful)
+ if (['done', 'dismiss', 'snooze'].includes(action)) {
+ taskCache.delete(req.userId!);
+ }
bus.publish('signals.tip.feedback', {
userId: req.userId!,
tipId,
- action: action as 'done' | 'dismiss' | 'snooze',
+ action: action as 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful',
reward,
createdAt: new Date().toISOString(),
});
+
if (task) {
fetch(`${config.ML_SERVING_URL}/reward`, {
method: 'POST',