feat: M1 admin console — all 10 remaining pages + signal/quality/ops infrastructure
Admin console (issues #63–72): - Event stream viewer: live-tail ring buffer (500 events) with subject/user filters - Feature store browser: per-user feature vector history from ml/serving - Model registry panel: MLflow embed at /admin/models - Experiment dashboard: LinUCB per-user stats (pulls, reward, θ) + bandit reset - Recommendation log: per-tip explainability (policy, score, features, latency) - Reward analytics: daily reaction breakdown + per-policy compare - Data quality widget: missing-feature rate, stale-token rate, daily completeness - Ops actions: replay-signal, policy enable/disable; user actions link to Users page - SQL runner: read-only SELECT runner with saved queries - Health rollup: fan-out to api/ml/sqlite/event-bus with auto-refresh Backend: - tip_scores table: logs features+policy+score+latency at every scoring call (#67) - saved_queries table: per-admin saved SQL (#71) - Event bus: 500-event ring buffer + tail() API (#63) - Admin routes: /events, /tips, /reward-analytics, /data-quality, /health, /policies, /replay-signal, /sql, /saved-queries endpoints - /api/ml/* admin-gated proxy to ml/serving (#64, #66) - Shadow-policy registry in recommender (#56) ML serving: - /reset/{user_id}: clear bandit state + feature history (#66) - /stats/{user_id}: pulls, cumulative reward, estimated mean, θ (#66) - /features/{user_id}: last 100 feature vectors logged at scoring time (#64) - Meta (pulls, rewards) persisted alongside A/b matrices Web: - Tip action sheet adds Helpful / Not helpful buttons (#62) - TipFeedback type extended with helpful/not_helpful actions - Rewards mapped: helpful=+0.5, not_helpful=−0.5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<K extends keyof EventMap>(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<K extends keyof EventMap>(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<string, unknown>;
|
||||
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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
609
services/api/src/routes/admin.ts
Normal file
609
services/api/src/routes/admin.ts
Normal file
@@ -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<number>`count(distinct user_id)` })
|
||||
.from(tipViews)
|
||||
.where(gte(tipViews.servedAt, dayAgo));
|
||||
|
||||
const [wauRow] = await db
|
||||
.select({ count: sql<number>`count(distinct user_id)` })
|
||||
.from(tipViews)
|
||||
.where(gte(tipViews.servedAt, weekAgo));
|
||||
|
||||
const [tipsRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tipViews)
|
||||
.where(gte(tipViews.servedAt, weekAgo));
|
||||
|
||||
const reactionRows = await db
|
||||
.select({ action: tipFeedback.action, count: sql<number>`count(*)` })
|
||||
.from(tipFeedback)
|
||||
.where(gte(tipFeedback.createdAt, weekAgo))
|
||||
.groupBy(tipFeedback.action);
|
||||
|
||||
const reactions: Record<string, number> = {};
|
||||
for (const row of reactionRows) reactions[row.action] = Number(row.count);
|
||||
|
||||
const [totalUsersRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(isNull(users.deletedAt));
|
||||
|
||||
const [activatedRow] = await db
|
||||
.select({ count: sql<number>`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<number>`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<number>`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<number>`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<typeof eq>])) : undefined)
|
||||
.orderBy(desc(tipScores.servedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tipScores)
|
||||
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : 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<string>`date(created_at)`,
|
||||
action: tipFeedback.action,
|
||||
count: sql<number>`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<number>`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<number>`count(*)`,
|
||||
avgHour: sql<number>`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<number>`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<number>`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<number>`count(*)` })
|
||||
.from(integrationTokens)
|
||||
.where(lt(integrationTokens.connectedAt, sevenDaysAgo));
|
||||
const staleTokens = Number(staleTokensRow?.count ?? 0);
|
||||
|
||||
const [totalTokensRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(integrationTokens);
|
||||
const totalTokens = Number(totalTokensRow?.count ?? 0);
|
||||
|
||||
// Daily feature completeness (last 14 days)
|
||||
const dailyQuality = await db
|
||||
.select({
|
||||
date: sql<string>`date(served_at)`,
|
||||
total: sql<number>`count(*)`,
|
||||
withFeatures: sql<number>`sum(case when features_json is not null then 1 else 0 end)`,
|
||||
avgCandidates: sql<number>`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<number>`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<string, unknown> };
|
||||
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 };
|
||||
@@ -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<string, { tasks: CachedTask[]; fetchedAt: number }>();
|
||||
|
||||
/** 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<policyName, { active: boolean }>
|
||||
const shadowPolicies = new Map<string, { active: boolean }>([
|
||||
// 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<C
|
||||
});
|
||||
|
||||
taskCache.set(userId, { tasks, fetchedAt: Date.now() });
|
||||
|
||||
bus.publish('signals.task.synced', { userId, count: tasks.length, syncedAt: now.toISOString() });
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/** Call ml/serving for scored selection; returns tip_id or null on failure */
|
||||
async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> {
|
||||
/** 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<string
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const { tip_id } = (await res.json()) as { tip_id: string };
|
||||
return tip_id;
|
||||
const data = (await res.json()) as { tip_id: string; score: number };
|
||||
return { tipId: data.tip_id, score: data.score };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -111,7 +141,9 @@ function randomPolicy(candidates: CachedTask[]): CachedTask | null {
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
}
|
||||
|
||||
/** POST /api/recommend */
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/recommend
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||
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<string, number> = {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user