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:
parent
c7c36e74b6
commit
20e0ef2201
@ -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> {
|
||||
|
||||
@ -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}$/);
|
||||
});
|
||||
});
|
||||
144
services/platform-service/src/modules/analytics/repository.ts
Normal file
144
services/platform-service/src/modules/analytics/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
50
services/platform-service/src/modules/analytics/routes.ts
Normal file
50
services/platform-service/src/modules/analytics/routes.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
52
services/platform-service/src/modules/analytics/types.ts
Normal file
52
services/platform-service/src/modules/analytics/types.ts
Normal 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>;
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
68
services/platform-service/src/modules/changelog/routes.ts
Normal file
68
services/platform-service/src/modules/changelog/routes.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
45
services/platform-service/src/modules/changelog/types.ts
Normal file
45
services/platform-service/src/modules/changelog/types.ts
Normal 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>;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
178
services/platform-service/src/modules/experiments/repository.ts
Normal file
178
services/platform-service/src/modules/experiments/repository.ts
Normal 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;
|
||||
}
|
||||
89
services/platform-service/src/modules/experiments/routes.ts
Normal file
89
services/platform-service/src/modules/experiments/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
78
services/platform-service/src/modules/experiments/types.ts
Normal file
78
services/platform-service/src/modules/experiments/types.ts
Normal 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; // 0–100, 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; // 0–100 — % 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>;
|
||||
105
services/platform-service/src/modules/feedback/feedback.test.ts
Normal file
105
services/platform-service/src/modules/feedback/feedback.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
118
services/platform-service/src/modules/feedback/repository.ts
Normal file
118
services/platform-service/src/modules/feedback/repository.ts
Normal 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;
|
||||
}
|
||||
82
services/platform-service/src/modules/feedback/routes.ts
Normal file
82
services/platform-service/src/modules/feedback/routes.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
54
services/platform-service/src/modules/feedback/types.ts
Normal file
54
services/platform-service/src/modules/feedback/types.ts
Normal 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; // 1–5 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>;
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
32
services/platform-service/src/modules/impersonation/types.ts
Normal file
32
services/platform-service/src/modules/impersonation/types.ts
Normal 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>;
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user