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:
@@ -105,3 +105,53 @@ export function readProfile(userId: string): Profile {
|
||||
for (const [name, row] of stored) out[name] = valueOf(row);
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,6 +190,62 @@ describe('GET /api/admin/users/:id', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { nanoid } from 'nanoid';
|
||||
import { bus } from '../events/bus.js';
|
||||
import { config } from '../config.js';
|
||||
import { getShadowPolicies, setPolicyActive } from './recommender.js';
|
||||
import { inspectProfile, rebuildProfile } from '../profile/builder.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
@@ -169,9 +170,38 @@ router.get('/users/:id', async (req: AuthenticatedRequest, res: Response) => {
|
||||
tipsServed: Number(tipStats?.count ?? 0),
|
||||
lastTipAt: lastView?.servedAt ?? null,
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user