feat(platform-service): add P2 Product Intelligence modules (5 modules, 61 tests)

- experiments: A/B testing with FNV-1a deterministic variant assignment (7 endpoints, 15 tests)
- analytics: metric ingest + daily/weekly/monthly rollup queries (3 endpoints, 14 tests)
- feedback: in-app bug/feature/praise submission + admin triage (6 endpoints, 14 tests)
- impersonation: admin user impersonation with audit trail (4 endpoints, 6 tests)
- changelog: public product changelog with admin CRUD (6 endpoints, 12 tests)
- 6 new Cosmos containers added to cosmos-init.ts
- All 5 route modules registered in server.ts
- 1,090 total platform-service tests passing
This commit is contained in:
saravanakumardb1 2026-02-28 14:04:07 -08:00
parent c7c36e74b6
commit 20e0ef2201
22 changed files with 1750 additions and 0 deletions

View File

@ -69,6 +69,13 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
telemetry_collection_policies: { partitionKeyPath: '/productId' },
// P2 — Product Intelligence
experiments: { partitionKeyPath: '/id' },
experiment_assignments: { partitionKeyPath: '/experimentId' },
analytics_rollups: { partitionKeyPath: '/productId' },
feedback: { partitionKeyPath: '/productId' },
impersonation_sessions: { partitionKeyPath: '/productId', defaultTtl: 90 * 86400 },
changelog: { partitionKeyPath: '/productId' },
};
export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -0,0 +1,102 @@
/**
* Analytics Rollups module unit tests.
*/
import { describe, it, expect } from 'vitest';
import { IngestMetricSchema, IngestBatchSchema, QueryRollupsSchema } from './types.js';
import { toDailyKey, toWeeklyKey, toMonthlyKey, getDateKey } from './repository.js';
// ── Schema Validation ────────────────────────────────────────
describe('IngestMetricSchema', () => {
it('accepts valid metric with defaults', () => {
const result = IngestMetricSchema.parse({ metric: 'signups' });
expect(result.metric).toBe('signups');
expect(result.value).toBe(1);
expect(result.timestamp).toBeUndefined();
});
it('accepts metric with dots and underscores', () => {
const result = IngestMetricSchema.parse({ metric: 'user.session_start', value: 5 });
expect(result.metric).toBe('user.session_start');
expect(result.value).toBe(5);
});
it('rejects empty metric name', () => {
expect(() => IngestMetricSchema.parse({ metric: '' })).toThrow();
});
it('rejects metric with invalid chars', () => {
expect(() => IngestMetricSchema.parse({ metric: 'BAD-METRIC' })).toThrow();
});
it('accepts explicit timestamp', () => {
const result = IngestMetricSchema.parse({
metric: 'logins',
timestamp: '2026-01-15T10:00:00.000Z',
});
expect(result.timestamp).toBe('2026-01-15T10:00:00.000Z');
});
});
describe('IngestBatchSchema', () => {
it('accepts batch of events', () => {
const result = IngestBatchSchema.parse({
events: [
{ metric: 'signups', value: 1 },
{ metric: 'logins', value: 3 },
],
});
expect(result.events).toHaveLength(2);
});
it('rejects empty batch', () => {
expect(() => IngestBatchSchema.parse({ events: [] })).toThrow();
});
});
describe('QueryRollupsSchema', () => {
it('accepts defaults', () => {
const result = QueryRollupsSchema.parse({});
expect(result.period).toBe('daily');
});
it('accepts all params', () => {
const result = QueryRollupsSchema.parse({
period: 'weekly',
from: '2026-01-01',
to: '2026-01-31',
metric: 'signups',
});
expect(result.period).toBe('weekly');
expect(result.metric).toBe('signups');
});
it('rejects invalid period', () => {
expect(() => QueryRollupsSchema.parse({ period: 'yearly' })).toThrow();
});
});
// ── Date Key Helpers ─────────────────────────────────────────
describe('date key helpers', () => {
it('toDailyKey returns YYYY-MM-DD', () => {
expect(toDailyKey(new Date('2026-03-15T10:00:00Z'))).toBe('2026-03-15');
});
it('toMonthlyKey returns YYYY-MM', () => {
expect(toMonthlyKey(new Date('2026-03-15T10:00:00Z'))).toBe('2026-03');
});
it('toWeeklyKey returns YYYY-Www format', () => {
const key = toWeeklyKey(new Date('2026-03-15T10:00:00Z'));
expect(key).toMatch(/^2026-W\d{2}$/);
});
it('getDateKey dispatches correctly', () => {
const d = new Date('2026-06-01T12:00:00Z');
expect(getDateKey(d, 'daily')).toBe('2026-06-01');
expect(getDateKey(d, 'monthly')).toBe('2026-06');
expect(getDateKey(d, 'weekly')).toMatch(/^2026-W\d{2}$/);
});
});

View File

@ -0,0 +1,144 @@
/**
* Analytics Rollups repository Cosmos DB CRUD + rollup logic.
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import type { AnalyticsRollupDoc, RollupPeriod, IngestMetricInput } from './types.js';
function getContainer() {
return getRegisteredContainer('analytics_rollups');
}
// ── Date helpers ─────────────────────────────────────────────
export function toDailyKey(date: Date): string {
return date.toISOString().slice(0, 10); // YYYY-MM-DD
}
export function toWeeklyKey(date: Date): string {
const jan1 = new Date(date.getFullYear(), 0, 1);
const dayOfYear = Math.ceil((date.getTime() - jan1.getTime()) / 86_400_000);
const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
export function toMonthlyKey(date: Date): string {
return date.toISOString().slice(0, 7); // YYYY-MM
}
export function getDateKey(date: Date, period: RollupPeriod): string {
switch (period) {
case 'daily':
return toDailyKey(date);
case 'weekly':
return toWeeklyKey(date);
case 'monthly':
return toMonthlyKey(date);
}
}
// ── Ingest ───────────────────────────────────────────────────
export async function ingestMetric(productId: string, event: IngestMetricInput): Promise<void> {
const ts = event.timestamp ? new Date(event.timestamp) : new Date();
// Increment daily rollup (other periods aggregated by scheduled job)
await incrementRollup(productId, 'daily', toDailyKey(ts), event.metric, event.value);
}
export async function ingestBatch(productId: string, events: IngestMetricInput[]): Promise<number> {
let count = 0;
for (const event of events) {
await ingestMetric(productId, event);
count++;
}
return count;
}
// ── Rollup CRUD ──────────────────────────────────────────────
async function incrementRollup(
productId: string,
period: RollupPeriod,
dateKey: string,
metric: string,
value: number
): Promise<void> {
const id = `${productId}:${period}:${dateKey}`;
const container = getContainer();
const now = new Date().toISOString();
try {
const { resource } = await container.item(id, productId).read<AnalyticsRollupDoc>();
if (resource) {
resource.metrics[metric] = (resource.metrics[metric] ?? 0) + value;
resource.updatedAt = now;
await container.item(id, productId).replace(resource);
return;
}
} catch {
// Does not exist — create
}
const doc: AnalyticsRollupDoc = {
id,
productId,
period,
date: dateKey,
metrics: { [metric]: value },
createdAt: now,
updatedAt: now,
};
await container.items.create(doc);
}
export async function queryRollups(
productId: string,
period: RollupPeriod,
from?: string,
to?: string,
metric?: string
): Promise<AnalyticsRollupDoc[]> {
const conditions = ['c.productId = @pid', 'c.period = @period'];
const params: { name: string; value: string }[] = [
{ name: '@pid', value: productId },
{ name: '@period', value: period },
];
if (from) {
conditions.push('c.date >= @from');
params.push({ name: '@from', value: from });
}
if (to) {
conditions.push('c.date <= @to');
params.push({ name: '@to', value: to });
}
const query = `SELECT * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.date ASC`;
const { resources } = await getContainer()
.items.query<AnalyticsRollupDoc>({ query, parameters: params })
.fetchAll();
// Filter to specific metric if requested
if (metric) {
return resources.map(r => ({
...r,
metrics: { [metric]: r.metrics[metric] ?? 0 },
}));
}
return resources;
}
export async function getRollup(
productId: string,
period: RollupPeriod,
dateKey: string
): Promise<AnalyticsRollupDoc | null> {
const id = `${productId}:${period}:${dateKey}`;
try {
const { resource } = await getContainer().item(id, productId).read<AnalyticsRollupDoc>();
return resource ?? null;
} catch {
return null;
}
}

View File

@ -0,0 +1,50 @@
/**
* Analytics Rollups routes.
* Authenticated: ingest metrics. Admin: query rollups.
*/
import type { FastifyInstance } from 'fastify';
import { UnauthorizedError, ForbiddenError } from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import { IngestMetricSchema, IngestBatchSchema, QueryRollupsSchema } from './types.js';
import { ingestMetric, ingestBatch, queryRollups } from './repository.js';
function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
return req.jwtPayload.sub;
}
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): void {
requireAuth(req);
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
}
export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
// ── Ingest single metric ──────────────────────────────────
app.post('/analytics/ingest', async (req, reply) => {
requireAuth(req);
const productId = getRequestProductId(req);
const event = IngestMetricSchema.parse(req.body);
await ingestMetric(productId, event);
reply.status(202);
return { status: 'accepted' };
});
// ── Ingest batch ──────────────────────────────────────────
app.post('/analytics/ingest/batch', async (req, reply) => {
requireAuth(req);
const productId = getRequestProductId(req);
const { events } = IngestBatchSchema.parse(req.body);
const count = await ingestBatch(productId, events);
reply.status(202);
return { status: 'accepted', count };
});
// ── Admin: Query rollups ──────────────────────────────────
app.get('/analytics/rollups', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const { period, from, to, metric } = QueryRollupsSchema.parse(req.query);
return queryRollups(productId, period, from, to, metric);
});
}

View File

@ -0,0 +1,52 @@
/**
* Analytics Rollups module types and schemas.
* Pre-aggregated daily/weekly/monthly metrics per product.
*/
import { z } from 'zod';
export type RollupPeriod = 'daily' | 'weekly' | 'monthly';
export interface AnalyticsRollupDoc {
id: string; // `${productId}:${period}:${date}`
productId: string;
period: RollupPeriod;
date: string; // YYYY-MM-DD (daily), YYYY-Www (weekly), YYYY-MM (monthly)
metrics: Record<string, number>;
createdAt: string;
updatedAt: string;
}
export interface MetricEvent {
productId: string;
metric: string; // e.g. 'signups', 'fasts_completed', 'timers_created'
value: number;
timestamp: string;
}
// ── Schemas ────────────────────────────────────────────────────
export const IngestMetricSchema = z.object({
metric: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9_.]+$/),
value: z.number().default(1),
timestamp: z.string().datetime().optional(),
});
export const IngestBatchSchema = z.object({
events: z.array(IngestMetricSchema).min(1).max(100),
});
export const QueryRollupsSchema = z.object({
period: z.enum(['daily', 'weekly', 'monthly']).default('daily'),
from: z.string().optional(), // YYYY-MM-DD
to: z.string().optional(), // YYYY-MM-DD
metric: z.string().optional(), // filter to specific metric
});
export type IngestMetricInput = z.infer<typeof IngestMetricSchema>;
export type IngestBatchInput = z.infer<typeof IngestBatchSchema>;
export type QueryRollupsInput = z.infer<typeof QueryRollupsSchema>;

View File

@ -0,0 +1,98 @@
/**
* Changelog module unit tests.
*/
import { describe, it, expect } from 'vitest';
import { CreateChangelogSchema, UpdateChangelogSchema } from './types.js';
describe('CreateChangelogSchema', () => {
it('accepts valid entry', () => {
const result = CreateChangelogSchema.parse({
version: '1.2.0',
title: 'New Dashboard',
categories: ['feature'],
});
expect(result.version).toBe('1.2.0');
expect(result.body).toBe('');
expect(result.published).toBe(false);
});
it('accepts entry with all fields', () => {
const result = CreateChangelogSchema.parse({
version: '2.0.0',
title: 'Major Release',
body: '## Breaking Changes\n- New API format',
categories: ['feature', 'breaking'],
published: true,
});
expect(result.categories).toHaveLength(2);
expect(result.published).toBe(true);
});
it('rejects empty version', () => {
expect(() =>
CreateChangelogSchema.parse({ version: '', title: 'Test', categories: ['fix'] })
).toThrow();
});
it('rejects empty title', () => {
expect(() =>
CreateChangelogSchema.parse({ version: '1.0.0', title: '', categories: ['fix'] })
).toThrow();
});
it('rejects empty categories', () => {
expect(() =>
CreateChangelogSchema.parse({ version: '1.0.0', title: 'Test', categories: [] })
).toThrow();
});
it('rejects invalid category', () => {
expect(() =>
CreateChangelogSchema.parse({ version: '1.0.0', title: 'Test', categories: ['unknown'] })
).toThrow();
});
it('rejects title over 200 chars', () => {
expect(() =>
CreateChangelogSchema.parse({ version: '1.0.0', title: 'x'.repeat(201), categories: ['fix'] })
).toThrow();
});
it('rejects body over 10000 chars', () => {
expect(() =>
CreateChangelogSchema.parse({
version: '1.0.0',
title: 'Test',
body: 'x'.repeat(10001),
categories: ['fix'],
})
).toThrow();
});
});
describe('UpdateChangelogSchema', () => {
it('accepts partial update', () => {
const result = UpdateChangelogSchema.parse({ title: 'Updated Title' });
expect(result.title).toBe('Updated Title');
expect(result.version).toBeUndefined();
});
it('accepts publish toggle', () => {
const result = UpdateChangelogSchema.parse({ published: true });
expect(result.published).toBe(true);
});
it('accepts category update', () => {
const result = UpdateChangelogSchema.parse({ categories: ['fix', 'security'] });
expect(result.categories).toEqual(['fix', 'security']);
});
it('rejects invalid category in update', () => {
expect(() => UpdateChangelogSchema.parse({ categories: ['invalid'] })).toThrow();
});
it('rejects empty categories array in update', () => {
expect(() => UpdateChangelogSchema.parse({ categories: [] })).toThrow();
});
});

View File

@ -0,0 +1,93 @@
/**
* Changelog repository Cosmos DB CRUD.
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import type { ChangelogEntryDoc, CreateChangelogInput, UpdateChangelogInput } from './types.js';
function getContainer() {
return getRegisteredContainer('changelog');
}
export async function createEntry(
productId: string,
input: CreateChangelogInput
): Promise<ChangelogEntryDoc> {
const now = new Date().toISOString();
const doc: ChangelogEntryDoc = {
id: `cl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
productId,
version: input.version,
title: input.title,
body: input.body ?? '',
categories: input.categories,
published: input.published ?? false,
publishedAt: input.published ? now : null,
createdAt: now,
updatedAt: now,
};
await getContainer().items.create(doc);
return doc;
}
export async function getEntry(id: string, productId: string): Promise<ChangelogEntryDoc | null> {
try {
const { resource } = await getContainer().item(id, productId).read<ChangelogEntryDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function listEntries(
productId: string,
publishedOnly: boolean = false
): Promise<ChangelogEntryDoc[]> {
const conditions = ['c.productId = @pid'];
if (publishedOnly) conditions.push('c.published = true');
const { resources } = await getContainer()
.items.query<ChangelogEntryDoc>({
query: `SELECT * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.createdAt DESC`,
parameters: [{ name: '@pid', value: productId }],
})
.fetchAll();
return resources;
}
export async function updateEntry(
id: string,
productId: string,
updates: UpdateChangelogInput
): Promise<ChangelogEntryDoc | null> {
const existing = await getEntry(id, productId);
if (!existing) return null;
const now = new Date().toISOString();
const updated: ChangelogEntryDoc = {
...existing,
...updates,
updatedAt: now,
};
// Set publishedAt on first publish
if (updates.published === true && !existing.publishedAt) {
updated.publishedAt = now;
}
// Clear publishedAt on unpublish
if (updates.published === false) {
updated.publishedAt = null;
}
await getContainer().item(id, productId).replace(updated);
return updated;
}
export async function deleteEntry(id: string, productId: string): Promise<boolean> {
try {
await getContainer().item(id, productId).delete();
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,68 @@
/**
* Changelog routes.
* Public: list published entries. Admin: CRUD all entries.
*/
import type { FastifyInstance } from 'fastify';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { getRequestProductId, getRequestProductIdForPublic } from '../../lib/request-context.js';
import { CreateChangelogSchema, UpdateChangelogSchema } from './types.js';
import { createEntry, getEntry, listEntries, updateEntry, deleteEntry } from './repository.js';
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
if (req.jwtPayload.role !== 'admin') throw new ForbiddenError('Admin access required');
return req.jwtPayload.sub;
}
export async function changelogRoutes(app: FastifyInstance): Promise<void> {
// ── Public: List published changelog ──────────────────────
app.get('/changelog', async req => {
const productId = getRequestProductIdForPublic(req);
return listEntries(productId, true);
});
// ── Public: Get single entry ──────────────────────────────
app.get<{ Params: { id: string } }>('/changelog/:id', async req => {
const productId = getRequestProductIdForPublic(req);
const entry = await getEntry(req.params.id, productId);
if (!entry || !entry.published) throw new NotFoundError('Changelog entry not found');
return entry;
});
// ── Admin: List all entries (including drafts) ────────────
app.get('/admin/changelog', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
return listEntries(productId, false);
});
// ── Admin: Create entry ───────────────────────────────────
app.post('/admin/changelog', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const input = CreateChangelogSchema.parse(req.body);
const entry = await createEntry(productId, input);
reply.status(201);
return entry;
});
// ── Admin: Update entry ───────────────────────────────────
app.put<{ Params: { id: string } }>('/admin/changelog/:id', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const updates = UpdateChangelogSchema.parse(req.body);
const entry = await updateEntry(req.params.id, productId, updates);
if (!entry) throw new NotFoundError('Changelog entry not found');
return entry;
});
// ── Admin: Delete entry ───────────────────────────────────
app.delete<{ Params: { id: string } }>('/admin/changelog/:id', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const ok = await deleteEntry(req.params.id, productId);
if (!ok) throw new NotFoundError('Changelog entry not found');
reply.status(204);
});
}

View File

@ -0,0 +1,45 @@
/**
* Changelog module types and schemas.
* Public product changelog entries managed by admins.
*/
import { z } from 'zod';
export type ChangelogCategory = 'feature' | 'improvement' | 'fix' | 'breaking' | 'security';
export interface ChangelogEntryDoc {
id: string;
productId: string;
version: string; // semver: '1.2.0'
title: string;
body: string; // markdown
categories: ChangelogCategory[];
published: boolean;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
// ── Schemas ────────────────────────────────────────────────────
export const CreateChangelogSchema = z.object({
version: z.string().min(1).max(50),
title: z.string().min(1).max(200),
body: z.string().max(10000).default(''),
categories: z.array(z.enum(['feature', 'improvement', 'fix', 'breaking', 'security'])).min(1),
published: z.boolean().default(false),
});
export const UpdateChangelogSchema = z.object({
version: z.string().min(1).max(50).optional(),
title: z.string().min(1).max(200).optional(),
body: z.string().max(10000).optional(),
categories: z
.array(z.enum(['feature', 'improvement', 'fix', 'breaking', 'security']))
.min(1)
.optional(),
published: z.boolean().optional(),
});
export type CreateChangelogInput = z.infer<typeof CreateChangelogSchema>;
export type UpdateChangelogInput = z.infer<typeof UpdateChangelogSchema>;

View File

@ -0,0 +1,141 @@
/**
* A/B Testing (Experiments) module unit tests.
*/
import { describe, it, expect } from 'vitest';
import { CreateExperimentSchema, UpdateExperimentSchema } from './types.js';
import { assignVariant } from './repository.js';
// ── Schema Validation ────────────────────────────────────────
describe('CreateExperimentSchema', () => {
const validInput = {
key: 'onboarding_v2',
name: 'Onboarding Flow V2',
variants: [
{ key: 'control', weight: 50 },
{ key: 'variant_a', weight: 50 },
],
};
it('accepts valid input with defaults', () => {
const result = CreateExperimentSchema.parse(validInput);
expect(result.key).toBe('onboarding_v2');
expect(result.description).toBe('');
expect(result.targetSegments).toEqual([]);
expect(result.trafficPercent).toBe(100);
});
it('rejects key with invalid chars', () => {
expect(() => CreateExperimentSchema.parse({ ...validInput, key: 'BAD-KEY' })).toThrow();
});
it('rejects fewer than 2 variants', () => {
expect(() =>
CreateExperimentSchema.parse({
...validInput,
variants: [{ key: 'only', weight: 100 }],
})
).toThrow();
});
it('rejects variant weights not summing to 100', () => {
expect(() =>
CreateExperimentSchema.parse({
...validInput,
variants: [
{ key: 'a', weight: 40 },
{ key: 'b', weight: 40 },
],
})
).toThrow(/sum to 100/);
});
it('accepts 3 variants summing to 100', () => {
const result = CreateExperimentSchema.parse({
...validInput,
variants: [
{ key: 'control', weight: 34 },
{ key: 'a', weight: 33 },
{ key: 'b', weight: 33 },
],
});
expect(result.variants).toHaveLength(3);
});
it('accepts trafficPercent between 1 and 100', () => {
const result = CreateExperimentSchema.parse({ ...validInput, trafficPercent: 50 });
expect(result.trafficPercent).toBe(50);
});
it('rejects trafficPercent of 0', () => {
expect(() => CreateExperimentSchema.parse({ ...validInput, trafficPercent: 0 })).toThrow();
});
});
describe('UpdateExperimentSchema', () => {
it('accepts partial updates', () => {
const result = UpdateExperimentSchema.parse({ name: 'New Name' });
expect(result.name).toBe('New Name');
expect(result.status).toBeUndefined();
});
it('accepts status transition', () => {
const result = UpdateExperimentSchema.parse({ status: 'running' });
expect(result.status).toBe('running');
});
it('rejects invalid status', () => {
expect(() => UpdateExperimentSchema.parse({ status: 'invalid' })).toThrow();
});
});
// ── Deterministic Assignment ─────────────────────────────────
describe('assignVariant', () => {
const variants = [
{ key: 'control', weight: 50, description: '' },
{ key: 'variant_a', weight: 50, description: '' },
];
it('assigns deterministically — same input = same output', () => {
const v1 = assignVariant('exp-1', 'user-a', variants);
const v2 = assignVariant('exp-1', 'user-a', variants);
expect(v1).toBe(v2);
});
it('distributes across variants for different users', () => {
const assignments = new Set<string>();
for (let i = 0; i < 100; i++) {
assignments.add(assignVariant('exp-1', `user-${i}`, variants));
}
// With 50/50 split and 100 users, both variants should appear
expect(assignments.size).toBe(2);
});
it('respects uneven weights', () => {
const unevenVariants = [
{ key: 'control', weight: 90, description: '' },
{ key: 'variant_a', weight: 10, description: '' },
];
let controlCount = 0;
for (let i = 0; i < 1000; i++) {
if (assignVariant('exp-2', `u-${i}`, unevenVariants) === 'control') controlCount++;
}
// Should be roughly 90% control (allow wide margin for hash distribution)
expect(controlCount).toBeGreaterThan(700);
expect(controlCount).toBeLessThan(990);
});
it('different experiments give different assignments for same user', () => {
// Not guaranteed but statistically likely with enough samples
let sameCount = 0;
for (let i = 0; i < 50; i++) {
const v1 = assignVariant(`exp-a-${i}`, 'user-x', variants);
const v2 = assignVariant(`exp-b-${i}`, 'user-x', variants);
if (v1 === v2) sameCount++;
}
// Should not be all the same (statistically near-impossible)
expect(sameCount).toBeLessThan(50);
});
});

View File

@ -0,0 +1,178 @@
/**
* A/B Testing (Experiments) repository Cosmos DB CRUD.
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import type {
ExperimentDoc,
ExperimentAssignmentDoc,
CreateExperimentInput,
UpdateExperimentInput,
} from './types.js';
function getContainer() {
return getRegisteredContainer('experiments');
}
function getAssignmentContainer() {
return getRegisteredContainer('experiment_assignments');
}
// ── FNV-1a hash for deterministic assignment ──────────────────
function fnv1a(str: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash;
}
export function assignVariant(
experimentId: string,
userId: string,
variants: ExperimentDoc['variants']
): string {
const hash = fnv1a(`${experimentId}:${userId}`);
const bucket = hash % 100;
let cumulative = 0;
for (const v of variants) {
cumulative += v.weight;
if (bucket < cumulative) return v.key;
}
return variants[variants.length - 1].key;
}
// ── Experiment CRUD ──────────────────────────────────────────
export async function listExperiments(productId: string): Promise<ExperimentDoc[]> {
const container = getContainer();
const { resources } = await container.items
.query<ExperimentDoc>({
query: 'SELECT * FROM c WHERE c.productId = @pid ORDER BY c.createdAt DESC',
parameters: [{ name: '@pid', value: productId }],
})
.fetchAll();
return resources;
}
export async function getExperiment(id: string): Promise<ExperimentDoc | null> {
try {
const { resource } = await getContainer().item(id, id).read<ExperimentDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createExperiment(
productId: string,
input: CreateExperimentInput
): Promise<ExperimentDoc> {
const now = new Date().toISOString();
const doc: ExperimentDoc = {
id: `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
productId,
key: input.key,
name: input.name,
description: input.description ?? '',
status: 'draft',
variants: input.variants.map(v => ({
key: v.key,
weight: v.weight,
description: v.description ?? '',
})),
targetSegments: input.targetSegments ?? [],
trafficPercent: input.trafficPercent ?? 100,
startedAt: null,
endedAt: null,
createdAt: now,
updatedAt: now,
};
await getContainer().items.create(doc);
return doc;
}
export async function updateExperiment(
id: string,
updates: UpdateExperimentInput
): Promise<ExperimentDoc | null> {
const existing = await getExperiment(id);
if (!existing) return null;
const now = new Date().toISOString();
const updated: ExperimentDoc = {
...existing,
...updates,
updatedAt: now,
};
if (updates.status === 'running' && !existing.startedAt) {
updated.startedAt = now;
}
if (updates.status === 'completed' && !existing.endedAt) {
updated.endedAt = now;
}
await getContainer().item(id, id).replace(updated);
return updated;
}
export async function deleteExperiment(id: string): Promise<boolean> {
try {
await getContainer().item(id, id).delete();
return true;
} catch {
return false;
}
}
// ── Assignments ──────────────────────────────────────────────
export async function getAssignment(
experimentId: string,
userId: string
): Promise<ExperimentAssignmentDoc | null> {
const id = `${experimentId}:${userId}`;
try {
const { resource } = await getAssignmentContainer()
.item(id, experimentId)
.read<ExperimentAssignmentDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function getOrCreateAssignment(
experiment: ExperimentDoc,
userId: string
): Promise<ExperimentAssignmentDoc> {
const existing = await getAssignment(experiment.id, userId);
if (existing) return existing;
const variantKey = assignVariant(experiment.id, userId, experiment.variants);
const doc: ExperimentAssignmentDoc = {
id: `${experiment.id}:${userId}`,
productId: experiment.productId,
experimentId: experiment.id,
userId,
variantKey,
assignedAt: new Date().toISOString(),
};
await getAssignmentContainer().items.create(doc);
return doc;
}
export async function listAssignmentsForExperiment(
experimentId: string
): Promise<ExperimentAssignmentDoc[]> {
const { resources } = await getAssignmentContainer()
.items.query<ExperimentAssignmentDoc>({
query: 'SELECT * FROM c WHERE c.experimentId = @eid',
parameters: [{ name: '@eid', value: experimentId }],
})
.fetchAll();
return resources;
}

View File

@ -0,0 +1,89 @@
/**
* A/B Testing (Experiments) routes.
* Admin: CRUD experiments. Authenticated users: get their variant assignment.
*/
import type { FastifyInstance } from 'fastify';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import { CreateExperimentSchema, UpdateExperimentSchema } from './types.js';
import {
listExperiments,
getExperiment,
createExperiment,
updateExperiment,
deleteExperiment,
getOrCreateAssignment,
listAssignmentsForExperiment,
} from './repository.js';
function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
return req.jwtPayload.sub;
}
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): void {
requireAuth(req);
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
}
export async function experimentRoutes(app: FastifyInstance): Promise<void> {
// ── Admin: List experiments ────────────────────────────────
app.get('/experiments', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
return listExperiments(productId);
});
// ── Admin: Get experiment ─────────────────────────────────
app.get<{ Params: { id: string } }>('/experiments/:id', async req => {
requireAdmin(req);
const experiment = await getExperiment(req.params.id);
if (!experiment) throw new NotFoundError('Experiment not found');
return experiment;
});
// ── Admin: Create experiment ──────────────────────────────
app.post('/experiments', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const input = CreateExperimentSchema.parse(req.body);
const experiment = await createExperiment(productId, input);
reply.status(201);
return experiment;
});
// ── Admin: Update experiment ──────────────────────────────
app.put<{ Params: { id: string } }>('/experiments/:id', async req => {
requireAdmin(req);
const updates = UpdateExperimentSchema.parse(req.body);
const experiment = await updateExperiment(req.params.id, updates);
if (!experiment) throw new NotFoundError('Experiment not found');
return experiment;
});
// ── Admin: Delete experiment ──────────────────────────────
app.delete<{ Params: { id: string } }>('/experiments/:id', async (req, reply) => {
requireAdmin(req);
const ok = await deleteExperiment(req.params.id);
if (!ok) throw new NotFoundError('Experiment not found');
reply.status(204);
});
// ── Admin: List assignments for experiment ────────────────
app.get<{ Params: { id: string } }>('/experiments/:id/assignments', async req => {
requireAdmin(req);
return listAssignmentsForExperiment(req.params.id);
});
// ── User: Get my variant for an experiment ────────────────
app.get<{ Params: { key: string } }>('/experiments/assign/:key', async req => {
const userId = requireAuth(req);
const productId = getRequestProductId(req);
const experiments = await listExperiments(productId);
const experiment = experiments.find(e => e.key === req.params.key && e.status === 'running');
if (!experiment) throw new NotFoundError('Experiment not found or not running');
const assignment = await getOrCreateAssignment(experiment, userId);
return { experimentKey: experiment.key, variant: assignment.variantKey };
});
}

View File

@ -0,0 +1,78 @@
/**
* A/B Testing (Experiments) module types and schemas.
* Deterministic variant assignment via FNV-1a hash (same as feature flags).
*/
import { z } from 'zod';
export type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed';
export interface ExperimentVariant {
key: string; // e.g. 'control', 'variant_a', 'variant_b'
weight: number; // 0100, must sum to 100
description: string;
}
export interface ExperimentDoc {
id: string;
productId: string;
key: string; // unique slug: 'onboarding_flow_v2'
name: string;
description: string;
status: ExperimentStatus;
variants: ExperimentVariant[];
targetSegments: string[]; // optional user segments
trafficPercent: number; // 0100 — % of eligible users enrolled
startedAt: string | null;
endedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ExperimentAssignmentDoc {
id: string; // `${experimentId}:${userId}`
productId: string;
experimentId: string;
userId: string;
variantKey: string;
assignedAt: string;
}
// ── Schemas ────────────────────────────────────────────────────
const VariantSchema = z.object({
key: z
.string()
.min(1)
.regex(/^[a-z0-9_]+$/),
weight: z.number().int().min(0).max(100),
description: z.string().default(''),
});
export const CreateExperimentSchema = z
.object({
key: z
.string()
.min(1)
.regex(/^[a-z0-9_]+$/),
name: z.string().min(1).max(200),
description: z.string().default(''),
variants: z.array(VariantSchema).min(2).max(10),
targetSegments: z.array(z.string()).default([]),
trafficPercent: z.number().int().min(1).max(100).default(100),
})
.refine(d => d.variants.reduce((s, v) => s + v.weight, 0) === 100, {
message: 'Variant weights must sum to 100',
path: ['variants'],
});
export const UpdateExperimentSchema = z.object({
name: z.string().min(1).max(200).optional(),
description: z.string().optional(),
status: z.enum(['draft', 'running', 'paused', 'completed']).optional(),
targetSegments: z.array(z.string()).optional(),
trafficPercent: z.number().int().min(1).max(100).optional(),
});
export type CreateExperimentInput = z.infer<typeof CreateExperimentSchema>;
export type UpdateExperimentInput = z.infer<typeof UpdateExperimentSchema>;

View File

@ -0,0 +1,105 @@
/**
* In-App Feedback module unit tests.
*/
import { describe, it, expect } from 'vitest';
import { CreateFeedbackSchema, UpdateFeedbackSchema, QueryFeedbackSchema } from './types.js';
describe('CreateFeedbackSchema', () => {
it('accepts valid bug report', () => {
const result = CreateFeedbackSchema.parse({
type: 'bug',
title: 'App crashes on timer start',
body: 'When I tap start, the app freezes',
});
expect(result.type).toBe('bug');
expect(result.title).toBe('App crashes on timer start');
expect(result.rating).toBeNull();
expect(result.screen).toBeNull();
});
it('accepts feature request with rating', () => {
const result = CreateFeedbackSchema.parse({
type: 'feature',
title: 'Dark mode',
rating: 4,
platform: 'ios',
});
expect(result.rating).toBe(4);
expect(result.platform).toBe('ios');
});
it('accepts praise with screen info', () => {
const result = CreateFeedbackSchema.parse({
type: 'praise',
title: 'Love the timer!',
screen: '/dashboard',
appVersion: '1.2.0',
});
expect(result.screen).toBe('/dashboard');
expect(result.appVersion).toBe('1.2.0');
});
it('rejects empty title', () => {
expect(() => CreateFeedbackSchema.parse({ type: 'bug', title: '' })).toThrow();
});
it('rejects invalid type', () => {
expect(() => CreateFeedbackSchema.parse({ type: 'complaint', title: 'test' })).toThrow();
});
it('rejects rating out of range', () => {
expect(() => CreateFeedbackSchema.parse({ type: 'praise', title: 'ok', rating: 0 })).toThrow();
expect(() => CreateFeedbackSchema.parse({ type: 'praise', title: 'ok', rating: 6 })).toThrow();
});
it('rejects title over 200 chars', () => {
expect(() => CreateFeedbackSchema.parse({ type: 'bug', title: 'x'.repeat(201) })).toThrow();
});
});
describe('UpdateFeedbackSchema', () => {
it('accepts status change', () => {
const result = UpdateFeedbackSchema.parse({ status: 'reviewed' });
expect(result.status).toBe('reviewed');
});
it('accepts admin notes', () => {
const result = UpdateFeedbackSchema.parse({ adminNotes: 'Tracked in JIRA-123' });
expect(result.adminNotes).toBe('Tracked in JIRA-123');
});
it('accepts null admin notes', () => {
const result = UpdateFeedbackSchema.parse({ adminNotes: null });
expect(result.adminNotes).toBeNull();
});
it('rejects invalid status', () => {
expect(() => UpdateFeedbackSchema.parse({ status: 'closed' })).toThrow();
});
});
describe('QueryFeedbackSchema', () => {
it('applies defaults', () => {
const result = QueryFeedbackSchema.parse({});
expect(result.limit).toBe(50);
expect(result.offset).toBe(0);
});
it('accepts all filters', () => {
const result = QueryFeedbackSchema.parse({
type: 'bug',
status: 'new',
limit: '20',
offset: '10',
});
expect(result.type).toBe('bug');
expect(result.status).toBe('new');
expect(result.limit).toBe(20);
expect(result.offset).toBe(10);
});
it('rejects limit over 100', () => {
expect(() => QueryFeedbackSchema.parse({ limit: '101' })).toThrow();
});
});

View File

@ -0,0 +1,118 @@
/**
* In-App Feedback repository Cosmos DB CRUD.
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import type {
FeedbackDoc,
CreateFeedbackInput,
UpdateFeedbackInput,
QueryFeedbackInput,
} from './types.js';
function getContainer() {
return getRegisteredContainer('feedback');
}
export async function createFeedback(
productId: string,
userId: string,
input: CreateFeedbackInput
): Promise<FeedbackDoc> {
const now = new Date().toISOString();
const doc: FeedbackDoc = {
id: `fb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
productId,
userId,
type: input.type,
title: input.title,
body: input.body ?? '',
rating: input.rating ?? null,
screen: input.screen ?? null,
appVersion: input.appVersion ?? null,
platform: input.platform ?? null,
status: 'new',
adminNotes: null,
createdAt: now,
updatedAt: now,
};
await getContainer().items.create(doc);
return doc;
}
export async function getFeedback(id: string, productId: string): Promise<FeedbackDoc | null> {
try {
const { resource } = await getContainer().item(id, productId).read<FeedbackDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function listFeedback(
productId: string,
query: QueryFeedbackInput
): Promise<FeedbackDoc[]> {
const conditions = ['c.productId = @pid'];
const params: { name: string; value: string | number }[] = [{ name: '@pid', value: productId }];
if (query.type) {
conditions.push('c.type = @type');
params.push({ name: '@type', value: query.type });
}
if (query.status) {
conditions.push('c.status = @status');
params.push({ name: '@status', value: query.status });
}
const sql = `SELECT * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit`;
params.push({ name: '@offset', value: query.offset ?? 0 });
params.push({ name: '@limit', value: query.limit ?? 50 });
const { resources } = await getContainer()
.items.query<FeedbackDoc>({ query: sql, parameters: params })
.fetchAll();
return resources;
}
export async function updateFeedback(
id: string,
productId: string,
updates: UpdateFeedbackInput
): Promise<FeedbackDoc | null> {
const existing = await getFeedback(id, productId);
if (!existing) return null;
const updated: FeedbackDoc = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
await getContainer().item(id, productId).replace(updated);
return updated;
}
export async function deleteFeedback(id: string, productId: string): Promise<boolean> {
try {
await getContainer().item(id, productId).delete();
return true;
} catch {
return false;
}
}
export async function getFeedbackStats(productId: string): Promise<Record<string, number>> {
const { resources } = await getContainer()
.items.query<{ type: string; cnt: number }>({
query: 'SELECT c.type, COUNT(1) AS cnt FROM c WHERE c.productId = @pid GROUP BY c.type',
parameters: [{ name: '@pid', value: productId }],
})
.fetchAll();
const stats: Record<string, number> = { bug: 0, feature: 0, praise: 0, other: 0, total: 0 };
for (const r of resources) {
stats[r.type] = r.cnt;
stats.total += r.cnt;
}
return stats;
}

View File

@ -0,0 +1,82 @@
/**
* In-App Feedback routes.
* Authenticated: submit feedback. Admin: list, triage, delete.
*/
import type { FastifyInstance } from 'fastify';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import { CreateFeedbackSchema, UpdateFeedbackSchema, QueryFeedbackSchema } from './types.js';
import {
createFeedback,
getFeedback,
listFeedback,
updateFeedback,
deleteFeedback,
getFeedbackStats,
} from './repository.js';
function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
return req.jwtPayload.sub;
}
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): void {
requireAuth(req);
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
}
export async function feedbackRoutes(app: FastifyInstance): Promise<void> {
// ── User: Submit feedback ─────────────────────────────────
app.post('/feedback', async (req, reply) => {
const userId = requireAuth(req);
const productId = getRequestProductId(req);
const input = CreateFeedbackSchema.parse(req.body);
const fb = await createFeedback(productId, userId, input);
reply.status(201);
return fb;
});
// ── Admin: List feedback ──────────────────────────────────
app.get('/feedback', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const query = QueryFeedbackSchema.parse(req.query);
return listFeedback(productId, query);
});
// ── Admin: Get single feedback ────────────────────────────
app.get<{ Params: { id: string } }>('/feedback/:id', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const fb = await getFeedback(req.params.id, productId);
if (!fb) throw new NotFoundError('Feedback not found');
return fb;
});
// ── Admin: Update feedback (triage) ───────────────────────
app.put<{ Params: { id: string } }>('/feedback/:id', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const updates = UpdateFeedbackSchema.parse(req.body);
const fb = await updateFeedback(req.params.id, productId, updates);
if (!fb) throw new NotFoundError('Feedback not found');
return fb;
});
// ── Admin: Delete feedback ────────────────────────────────
app.delete<{ Params: { id: string } }>('/feedback/:id', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const ok = await deleteFeedback(req.params.id, productId);
if (!ok) throw new NotFoundError('Feedback not found');
reply.status(204);
});
// ── Admin: Feedback stats ─────────────────────────────────
app.get('/feedback/stats', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
return getFeedbackStats(productId);
});
}

View File

@ -0,0 +1,54 @@
/**
* In-App Feedback module types and schemas.
* Users submit feedback (bug, feature, praise); admins triage.
*/
import { z } from 'zod';
export type FeedbackType = 'bug' | 'feature' | 'praise' | 'other';
export type FeedbackStatus = 'new' | 'reviewed' | 'planned' | 'resolved' | 'wont_fix';
export interface FeedbackDoc {
id: string;
productId: string;
userId: string;
type: FeedbackType;
title: string;
body: string;
rating: number | null; // 15 optional satisfaction rating
screen: string | null; // which screen/page the feedback came from
appVersion: string | null;
platform: string | null; // web, ios, android
status: FeedbackStatus;
adminNotes: string | null;
createdAt: string;
updatedAt: string;
}
// ── Schemas ────────────────────────────────────────────────────
export const CreateFeedbackSchema = z.object({
type: z.enum(['bug', 'feature', 'praise', 'other']),
title: z.string().min(1).max(200),
body: z.string().max(5000).default(''),
rating: z.number().int().min(1).max(5).nullable().default(null),
screen: z.string().max(100).nullable().default(null),
appVersion: z.string().max(50).nullable().default(null),
platform: z.string().max(20).nullable().default(null),
});
export const UpdateFeedbackSchema = z.object({
status: z.enum(['new', 'reviewed', 'planned', 'resolved', 'wont_fix']).optional(),
adminNotes: z.string().max(2000).nullable().optional(),
});
export const QueryFeedbackSchema = z.object({
type: z.enum(['bug', 'feature', 'praise', 'other']).optional(),
status: z.enum(['new', 'reviewed', 'planned', 'resolved', 'wont_fix']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export type CreateFeedbackInput = z.infer<typeof CreateFeedbackSchema>;
export type UpdateFeedbackInput = z.infer<typeof UpdateFeedbackSchema>;
export type QueryFeedbackInput = z.infer<typeof QueryFeedbackSchema>;

View File

@ -0,0 +1,42 @@
/**
* User Impersonation module unit tests.
*/
import { describe, it, expect } from 'vitest';
import { StartImpersonationSchema, StopImpersonationSchema } from './types.js';
describe('StartImpersonationSchema', () => {
it('accepts valid input', () => {
const result = StartImpersonationSchema.parse({
targetUserId: 'user-123',
reason: 'Debugging timer sync issue',
});
expect(result.targetUserId).toBe('user-123');
expect(result.reason).toBe('Debugging timer sync issue');
});
it('rejects empty targetUserId', () => {
expect(() => StartImpersonationSchema.parse({ targetUserId: '', reason: 'test' })).toThrow();
});
it('rejects empty reason', () => {
expect(() => StartImpersonationSchema.parse({ targetUserId: 'u1', reason: '' })).toThrow();
});
it('rejects reason over 500 chars', () => {
expect(() =>
StartImpersonationSchema.parse({ targetUserId: 'u1', reason: 'x'.repeat(501) })
).toThrow();
});
});
describe('StopImpersonationSchema', () => {
it('accepts valid sessionId', () => {
const result = StopImpersonationSchema.parse({ sessionId: 'imp-abc123' });
expect(result.sessionId).toBe('imp-abc123');
});
it('rejects empty sessionId', () => {
expect(() => StopImpersonationSchema.parse({ sessionId: '' })).toThrow();
});
});

View File

@ -0,0 +1,82 @@
/**
* User Impersonation repository Cosmos DB CRUD.
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import type { ImpersonationSessionDoc, StartImpersonationInput } from './types.js';
function getContainer() {
return getRegisteredContainer('impersonation_sessions');
}
export async function startImpersonation(
productId: string,
adminUserId: string,
input: StartImpersonationInput
): Promise<ImpersonationSessionDoc> {
const now = new Date().toISOString();
const doc: ImpersonationSessionDoc = {
id: `imp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
productId,
adminUserId,
targetUserId: input.targetUserId,
reason: input.reason,
active: true,
startedAt: now,
endedAt: null,
createdAt: now,
};
await getContainer().items.create(doc);
return doc;
}
export async function stopImpersonation(
sessionId: string,
productId: string
): Promise<ImpersonationSessionDoc | null> {
try {
const { resource } = await getContainer()
.item(sessionId, productId)
.read<ImpersonationSessionDoc>();
if (!resource || !resource.active) return null;
const updated = { ...resource, active: false, endedAt: new Date().toISOString() };
await getContainer().item(sessionId, productId).replace(updated);
return updated;
} catch {
return null;
}
}
export async function getActiveSession(
productId: string,
adminUserId: string
): Promise<ImpersonationSessionDoc | null> {
const { resources } = await getContainer()
.items.query<ImpersonationSessionDoc>({
query:
'SELECT * FROM c WHERE c.productId = @pid AND c.adminUserId = @aid AND c.active = true',
parameters: [
{ name: '@pid', value: productId },
{ name: '@aid', value: adminUserId },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function listSessions(
productId: string,
limit: number = 50
): Promise<ImpersonationSessionDoc[]> {
const { resources } = await getContainer()
.items.query<ImpersonationSessionDoc>({
query:
'SELECT * FROM c WHERE c.productId = @pid ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit',
parameters: [
{ name: '@pid', value: productId },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
}

View File

@ -0,0 +1,79 @@
/**
* User Impersonation routes.
* Admin-only: start/stop impersonation, list sessions.
*/
import type { FastifyInstance } from 'fastify';
import {
UnauthorizedError,
ForbiddenError,
NotFoundError,
BadRequestError,
} from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import { StartImpersonationSchema, StopImpersonationSchema } from './types.js';
import {
startImpersonation,
stopImpersonation,
getActiveSession,
listSessions,
} from './repository.js';
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
if (req.jwtPayload.role !== 'admin') throw new ForbiddenError('Admin access required');
return req.jwtPayload.sub;
}
export async function impersonationRoutes(app: FastifyInstance): Promise<void> {
// ── Start impersonation ───────────────────────────────────
app.post('/impersonation/start', async (req, reply) => {
const adminUserId = requireAdmin(req);
const productId = getRequestProductId(req);
const input = StartImpersonationSchema.parse(req.body);
if (input.targetUserId === adminUserId) {
throw new BadRequestError('Cannot impersonate yourself');
}
// Check for existing active session
const existing = await getActiveSession(productId, adminUserId);
if (existing) {
throw new BadRequestError('Already impersonating a user. Stop current session first.');
}
const session = await startImpersonation(productId, adminUserId, input);
req.log.info(
{ adminUserId, targetUserId: input.targetUserId, sessionId: session.id },
'impersonation started'
);
reply.status(201);
return session;
});
// ── Stop impersonation ────────────────────────────────────
app.post('/impersonation/stop', async req => {
const adminUserId = requireAdmin(req);
const productId = getRequestProductId(req);
const { sessionId } = StopImpersonationSchema.parse(req.body);
const session = await stopImpersonation(sessionId, productId);
if (!session) throw new NotFoundError('Session not found or already ended');
req.log.info({ adminUserId, sessionId }, 'impersonation stopped');
return session;
});
// ── Get my active session ─────────────────────────────────
app.get('/impersonation/active', async req => {
const adminUserId = requireAdmin(req);
const productId = getRequestProductId(req);
const session = await getActiveSession(productId, adminUserId);
return { session };
});
// ── List all sessions (audit trail) ───────────────────────
app.get('/impersonation/sessions', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
return listSessions(productId);
});
}

View File

@ -0,0 +1,32 @@
/**
* User Impersonation module types and schemas.
* Admin-only: temporarily act as another user for debugging.
* All impersonation sessions are audit-logged.
*/
import { z } from 'zod';
export interface ImpersonationSessionDoc {
id: string;
productId: string;
adminUserId: string; // the admin performing impersonation
targetUserId: string; // the user being impersonated
reason: string;
active: boolean;
startedAt: string;
endedAt: string | null;
createdAt: string;
}
// ── Schemas ────────────────────────────────────────────────────
export const StartImpersonationSchema = z.object({
targetUserId: z.string().min(1),
reason: z.string().min(1).max(500),
});
export const StopImpersonationSchema = z.object({
sessionId: z.string().min(1),
});
export type StartImpersonationInput = z.infer<typeof StartImpersonationSchema>;

View File

@ -64,6 +64,11 @@ import { sessionRoutes } from './modules/sessions/routes.js';
import { maintenanceRoutes } from './modules/maintenance/routes.js';
import { exportRoutes } from './modules/exports/routes.js';
import { ipRuleRoutes } from './modules/ip-rules/routes.js';
import { experimentRoutes } from './modules/experiments/routes.js';
import { analyticsRoutes } from './modules/analytics/routes.js';
import { feedbackRoutes } from './modules/feedback/routes.js';
import { impersonationRoutes } from './modules/impersonation/routes.js';
import { changelogRoutes } from './modules/changelog/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
@ -165,5 +170,11 @@ await app.register(maintenanceRoutes, { prefix: '/api' });
await app.register(exportRoutes, { prefix: '/api' });
// IP allow/deny rules
await app.register(ipRuleRoutes, { prefix: '/api' });
// P2 — Product Intelligence
await app.register(experimentRoutes, { prefix: '/api' });
await app.register(analyticsRoutes, { prefix: '/api' });
await app.register(feedbackRoutes, { prefix: '/api' });
await app.register(impersonationRoutes, { prefix: '/api' });
await app.register(changelogRoutes, { prefix: '/api' });
await startService(app, { port: config.PORT, host: config.HOST });