feat(admin): per-user profile view + rebuild action (#81 phase B.1)

Surfaces phase A's profile features in /admin/users/:id so we can verify
they're actually computing useful values before investing in bandit
consumption. The detail GET now includes profile rows joined with registry
metadata (name, value, age, fresh badge, ttlSec, description). Read does
NOT trigger compute — staleness must be visible. A new POST
.../profile/rebuild button force-recomputes and is audit-logged like
reset-bandit.

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:27:08 +00:00
parent 7d4c29e137
commit 9e96540bcc
7 changed files with 233 additions and 6 deletions

View File

@@ -14,7 +14,7 @@ Next.js 15 app. Deployed at `admin.o.alogins.net` (dev: `http://localhost:3080`)
|-------|-------------| |-------|-------------|
| `/` | Overview: DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel | | `/` | Overview: DAU/WAU KPI cards, tips served, reaction breakdown, activation funnel |
| `/users` | User list (paginated) | | `/users` | User list (paginated) |
| `/users/:id` | User detail: identity, consents, integrations, tip stats, reward history; revoke-integration + reset-bandit actions | | `/users/:id` | User detail: identity, consents, integrations, profile features (#81 phase B), tip stats, reward history; revoke-integration + reset-bandit + rebuild-profile actions |
| `/audit` | Admin action audit log | | `/audit` | Admin action audit log |
| `/events` | Event stream viewer (stub — pending API history endpoint) | | `/events` | Event stream viewer (stub — pending API history endpoint) |
| `/reward-analytics` | Reaction distribution + per-policy / per-model / per-prompt-version / per-tip-kind breakdowns with avg reward | | `/reward-analytics` | Reaction distribution + per-policy / per-model / per-prompt-version / per-tip-kind breakdowns with avg reward |

View File

@@ -1,7 +1,14 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getUserDetail, revokeIntegration, resetBandit, type AdminUserDetail } from '@/lib/api'; import {
getUserDetail,
revokeIntegration,
resetBandit,
rebuildUserProfile,
type AdminUserDetail,
type ProfileFeatureView,
} from '@/lib/api';
export function UserDetail({ userId }: { userId: string }) { export function UserDetail({ userId }: { userId: string }) {
const [data, setData] = useState<AdminUserDetail | null>(null); const [data, setData] = useState<AdminUserDetail | null>(null);
@@ -44,10 +51,22 @@ export function UserDetail({ userId }: { userId: string }) {
} }
} }
async function handleRebuildProfile() {
setBusy('profile');
try {
const { profile } = await rebuildUserProfile(userId);
setData((d) => (d ? { ...d, profile } : d));
} catch (e: unknown) {
alert(`Failed: ${(e as Error).message}`);
} finally {
setBusy(null);
}
}
if (error) return <p className="text-red-400 text-sm">Error: {error}</p>; if (error) return <p className="text-red-400 text-sm">Error: {error}</p>;
if (!data) return <p className="text-gray-500 text-sm">Loading</p>; if (!data) return <p className="text-gray-500 text-sm">Loading</p>;
const { user, integrations, tipsServed, lastTipAt, recentFeedback } = data; const { user, integrations, tipsServed, lastTipAt, recentFeedback, profile } = data;
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
@@ -102,6 +121,22 @@ export function UserDetail({ userId }: { userId: string }) {
)} )}
</Section> </Section>
{/* Profile features (#81 phase B) */}
<Section
title="Profile features"
action={
<button
onClick={handleRebuildProfile}
disabled={busy === 'profile'}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors disabled:opacity-40"
>
{busy === 'profile' ? 'Rebuilding…' : 'Rebuild'}
</button>
}
>
<ProfileTable rows={profile} />
</Section>
{/* Tip stats */} {/* Tip stats */}
<Section title="Tip activity"> <Section title="Tip activity">
<Row label="Tips served (all time)" value={String(tipsServed)} /> <Row label="Tips served (all time)" value={String(tipsServed)} />
@@ -140,15 +175,52 @@ export function UserDetail({ userId }: { userId: string }) {
); );
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2"> <div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4 space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium mb-3">{title}</p> <div className="flex items-center justify-between mb-3">
<p className="text-xs text-gray-500 uppercase tracking-widest font-medium">{title}</p>
{action}
</div>
{children} {children}
</div> </div>
); );
} }
function ProfileTable({ rows }: { rows: ProfileFeatureView[] }) {
if (rows.length === 0) return <p className="text-sm text-gray-500">No profile features registered.</p>;
return (
<div className="space-y-1">
{rows.map((r) => (
<div key={r.name} className="flex items-baseline gap-3 text-sm">
<span className="w-44 flex-shrink-0 text-gray-500 font-mono text-xs" title={r.description}>
{r.name}
</span>
<span className="text-gray-200 tabular-nums w-24">{formatValue(r)}</span>
<span className="text-xs text-gray-500 tabular-nums">{formatAge(r)}</span>
</div>
))}
</div>
);
}
function formatValue(r: ProfileFeatureView): string {
if (r.value == null) return '—';
if (r.dtype === 'numeric') {
const n = Number(r.value);
return Math.abs(n) < 10 ? n.toFixed(3) : n.toFixed(0);
}
return String(r.value);
}
function formatAge(r: ProfileFeatureView): string {
if (r.ageSec == null) return 'never computed';
const mins = r.ageSec / 60;
const ageLabel = mins < 60 ? `${mins.toFixed(0)}m` : mins < 1440 ? `${(mins / 60).toFixed(1)}h` : `${(mins / 1440).toFixed(1)}d`;
const tag = r.fresh ? 'fresh' : 'stale';
return `${ageLabel} (${tag})`;
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex items-baseline gap-3 text-sm"> <div className="flex items-baseline gap-3 text-sm">

View File

@@ -36,12 +36,24 @@ export interface AdminUser {
deletedAt: string | null; deletedAt: string | null;
} }
export interface ProfileFeatureView {
name: string;
value: number | string | null;
updatedAt: string | null;
ageSec: number | null;
fresh: boolean;
ttlSec: number;
dtype: 'numeric' | 'categorical';
description: string;
}
export interface AdminUserDetail { export interface AdminUserDetail {
user: AdminUser; user: AdminUser;
integrations: { provider: string; connectedAt: string }[]; integrations: { provider: string; connectedAt: string }[];
tipsServed: number; tipsServed: number;
lastTipAt: string | null; lastTipAt: string | null;
recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[]; recentFeedback: { id: string; action: string; createdAt: string; tipId: string }[];
profile: ProfileFeatureView[];
} }
export interface AuditAction { export interface AuditAction {
@@ -135,6 +147,13 @@ export function resetBandit(userId: string) {
}); });
} }
export function rebuildUserProfile(userId: string) {
return apiFetch<{ ok: boolean; profile: ProfileFeatureView[] }>(
`/admin/users/${userId}/profile/rebuild`,
{ method: 'POST' },
);
}
export function getAuditLog(limit = 50, offset = 0) { export function getAuditLog(limit = 50, offset = 0) {
return apiFetch<{ actions: AuditAction[]; total: number }>( return apiFetch<{ actions: AuditAction[]; total: number }>(
`/admin/audit?limit=${limit}&offset=${offset}`, `/admin/audit?limit=${limit}&offset=${offset}`,

File diff suppressed because one or more lines are too long

View File

@@ -105,3 +105,53 @@ export function readProfile(userId: string): Profile {
for (const [name, row] of stored) out[name] = valueOf(row); for (const [name, row] of stored) out[name] = valueOf(row);
return out; return out;
} }
export interface ProfileFeatureView {
name: string;
value: number | string | null;
/** When the row was last computed; null if not stored yet. */
updatedAt: string | null;
/** Seconds since updatedAt; null if not stored. */
ageSec: number | null;
/** Whether the row is within its TTL window (false also when not stored). */
fresh: boolean;
ttlSec: number;
dtype: 'numeric' | 'categorical';
description: string;
}
/**
* Inspection helper for the admin UI: returns one row per registered feature,
* joining stored value + metadata. No compute — surface staleness; rebuild is
* a separate explicit action so reading a user doesn't quietly refresh state.
*/
export function inspectProfile(userId: string): ProfileFeatureView[] {
const stored = readStored(userId);
const now = Date.now();
return FEATURES.map((f) => {
const row = stored.get(f.name);
if (!row) {
return {
name: f.name,
value: null,
updatedAt: null,
ageSec: null,
fresh: false,
ttlSec: f.ttlSec,
dtype: f.dtype,
description: f.description,
};
}
const ageSec = (now - new Date(row.updated_at).getTime()) / 1000;
return {
name: f.name,
value: valueOf(row),
updatedAt: row.updated_at,
ageSec: Math.max(0, ageSec),
fresh: isFresh(row, now),
ttlSec: f.ttlSec,
dtype: f.dtype,
description: f.description,
};
});
}

View File

@@ -190,6 +190,62 @@ describe('GET /api/admin/users/:id', () => {
server.close(); server.close();
} }
}); });
it('includes profile feature views (#81 phase B)', async () => {
const { server, call } = await startServer(buildApp());
try {
const { status, body } = await call('GET', '/api/admin/users/user-1');
expect(status).toBe(200);
const b = body as { profile: Array<{ name: string; value: number | string | null; fresh: boolean; ageSec: number | null; ttlSec: number; description: string }> };
expect(Array.isArray(b.profile)).toBe(true);
// 5 features registered
expect(b.profile.length).toBe(5);
const names = b.profile.map((p) => p.name).sort();
expect(names).toEqual([
'completion_rate_30d',
'dismiss_rate_30d',
'mean_dwell_ms_30d',
'preferred_hour',
'tip_volume_30d',
]);
// Read endpoint must NOT trigger compute → all rows fresh=false, ageSec=null
for (const r of b.profile) {
expect(r.fresh).toBe(false);
expect(r.ageSec).toBeNull();
expect(r.ttlSec).toBeGreaterThan(0);
expect(r.description.length).toBeGreaterThan(0);
}
} finally {
server.close();
}
});
});
describe('POST /api/admin/users/:id/profile/rebuild — #81 phase B', () => {
it('recomputes profile, returns fresh values, audit-logs the action', async () => {
const { server, call } = await startServer(buildApp());
try {
const { status, body } = await call('POST', '/api/admin/users/user-1/profile/rebuild');
expect(status).toBe(200);
const b = body as { ok: boolean; profile: Array<{ name: string; value: number | null; fresh: boolean }> };
expect(b.ok).toBe(true);
const tipVolume = b.profile.find((r) => r.name === 'tip_volume_30d')!;
expect(tipVolume.value).toBe(2); // seed has tv-1 + tv-2
expect(tipVolume.fresh).toBe(true);
} finally {
server.close();
}
});
it('returns 404 for unknown user', async () => {
const { server, call } = await startServer(buildApp());
try {
const { status } = await call('POST', '/api/admin/users/nonexistent/profile/rebuild');
expect(status).toBe(404);
} finally {
server.close();
}
});
}); });
describe('GET /api/admin/audit', () => { describe('GET /api/admin/audit', () => {

View File

@@ -18,6 +18,7 @@ import { nanoid } from 'nanoid';
import { bus } from '../events/bus.js'; import { bus } from '../events/bus.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { getShadowPolicies, setPolicyActive } from './recommender.js'; import { getShadowPolicies, setPolicyActive } from './recommender.js';
import { inspectProfile, rebuildProfile } from '../profile/builder.js';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { existsSync, readFileSync, unlinkSync } from 'fs'; import { existsSync, readFileSync, unlinkSync } from 'fs';
import { resolve, dirname } from 'path'; import { resolve, dirname } from 'path';
@@ -169,9 +170,38 @@ router.get('/users/:id', async (req: AuthenticatedRequest, res: Response) => {
tipsServed: Number(tipStats?.count ?? 0), tipsServed: Number(tipStats?.count ?? 0),
lastTipAt: lastView?.servedAt ?? null, lastTipAt: lastView?.servedAt ?? null,
recentFeedback, recentFeedback,
profile: inspectProfile(user.id),
}); });
}); });
// ---------------------------------------------------------------------------
// POST /api/admin/users/:id/profile/rebuild
// Force-recompute every profile feature for the user (#81 phase B).
// Audit-logged.
// ---------------------------------------------------------------------------
router.post(
'/users/:id/profile/rebuild',
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.id as string;
const [user] = await db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).limit(1);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
await rebuildProfile(targetUserId);
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'rebuild_profile',
targetType: 'user',
targetId: targetUserId,
detail: null,
createdAt: new Date().toISOString(),
});
res.json({ ok: true, profile: inspectProfile(targetUserId) });
},
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /api/admin/users/:id/revoke-integration // POST /api/admin/users/:id/revoke-integration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------