diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 09ba48ab..7a080a4a 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -69,6 +69,13 @@ const CONTAINER_DEFS: Record = { 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 { diff --git a/services/platform-service/src/modules/analytics/analytics.test.ts b/services/platform-service/src/modules/analytics/analytics.test.ts new file mode 100644 index 00000000..84004245 --- /dev/null +++ b/services/platform-service/src/modules/analytics/analytics.test.ts @@ -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}$/); + }); +}); diff --git a/services/platform-service/src/modules/analytics/repository.ts b/services/platform-service/src/modules/analytics/repository.ts new file mode 100644 index 00000000..be20c12f --- /dev/null +++ b/services/platform-service/src/modules/analytics/repository.ts @@ -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 { + 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 { + 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 { + const id = `${productId}:${period}:${dateKey}`; + const container = getContainer(); + const now = new Date().toISOString(); + + try { + const { resource } = await container.item(id, productId).read(); + 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 { + 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({ 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 { + const id = `${productId}:${period}:${dateKey}`; + try { + const { resource } = await getContainer().item(id, productId).read(); + return resource ?? null; + } catch { + return null; + } +} diff --git a/services/platform-service/src/modules/analytics/routes.ts b/services/platform-service/src/modules/analytics/routes.ts new file mode 100644 index 00000000..bf8daac0 --- /dev/null +++ b/services/platform-service/src/modules/analytics/routes.ts @@ -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 { + // ── 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); + }); +} diff --git a/services/platform-service/src/modules/analytics/types.ts b/services/platform-service/src/modules/analytics/types.ts new file mode 100644 index 00000000..8e135d40 --- /dev/null +++ b/services/platform-service/src/modules/analytics/types.ts @@ -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; + 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; +export type IngestBatchInput = z.infer; +export type QueryRollupsInput = z.infer; diff --git a/services/platform-service/src/modules/changelog/changelog.test.ts b/services/platform-service/src/modules/changelog/changelog.test.ts new file mode 100644 index 00000000..71561531 --- /dev/null +++ b/services/platform-service/src/modules/changelog/changelog.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/changelog/repository.ts b/services/platform-service/src/modules/changelog/repository.ts new file mode 100644 index 00000000..f4066242 --- /dev/null +++ b/services/platform-service/src/modules/changelog/repository.ts @@ -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 { + 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 { + try { + const { resource } = await getContainer().item(id, productId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listEntries( + productId: string, + publishedOnly: boolean = false +): Promise { + const conditions = ['c.productId = @pid']; + if (publishedOnly) conditions.push('c.published = true'); + + const { resources } = await getContainer() + .items.query({ + 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 { + 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 { + try { + await getContainer().item(id, productId).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/changelog/routes.ts b/services/platform-service/src/modules/changelog/routes.ts new file mode 100644 index 00000000..c38c80b0 --- /dev/null +++ b/services/platform-service/src/modules/changelog/routes.ts @@ -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 { + // ── 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); + }); +} diff --git a/services/platform-service/src/modules/changelog/types.ts b/services/platform-service/src/modules/changelog/types.ts new file mode 100644 index 00000000..e12cb161 --- /dev/null +++ b/services/platform-service/src/modules/changelog/types.ts @@ -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; +export type UpdateChangelogInput = z.infer; diff --git a/services/platform-service/src/modules/experiments/experiments.test.ts b/services/platform-service/src/modules/experiments/experiments.test.ts new file mode 100644 index 00000000..1e576e18 --- /dev/null +++ b/services/platform-service/src/modules/experiments/experiments.test.ts @@ -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(); + 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); + }); +}); diff --git a/services/platform-service/src/modules/experiments/repository.ts b/services/platform-service/src/modules/experiments/repository.ts new file mode 100644 index 00000000..770a0823 --- /dev/null +++ b/services/platform-service/src/modules/experiments/repository.ts @@ -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 { + const container = getContainer(); + const { resources } = await container.items + .query({ + 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 { + try { + const { resource } = await getContainer().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createExperiment( + productId: string, + input: CreateExperimentInput +): Promise { + 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 { + 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 { + try { + await getContainer().item(id, id).delete(); + return true; + } catch { + return false; + } +} + +// ── Assignments ────────────────────────────────────────────── + +export async function getAssignment( + experimentId: string, + userId: string +): Promise { + const id = `${experimentId}:${userId}`; + try { + const { resource } = await getAssignmentContainer() + .item(id, experimentId) + .read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function getOrCreateAssignment( + experiment: ExperimentDoc, + userId: string +): Promise { + 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 { + const { resources } = await getAssignmentContainer() + .items.query({ + query: 'SELECT * FROM c WHERE c.experimentId = @eid', + parameters: [{ name: '@eid', value: experimentId }], + }) + .fetchAll(); + return resources; +} diff --git a/services/platform-service/src/modules/experiments/routes.ts b/services/platform-service/src/modules/experiments/routes.ts new file mode 100644 index 00000000..d57dedd4 --- /dev/null +++ b/services/platform-service/src/modules/experiments/routes.ts @@ -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 { + // ── 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 }; + }); +} diff --git a/services/platform-service/src/modules/experiments/types.ts b/services/platform-service/src/modules/experiments/types.ts new file mode 100644 index 00000000..4508e702 --- /dev/null +++ b/services/platform-service/src/modules/experiments/types.ts @@ -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; +export type UpdateExperimentInput = z.infer; diff --git a/services/platform-service/src/modules/feedback/feedback.test.ts b/services/platform-service/src/modules/feedback/feedback.test.ts new file mode 100644 index 00000000..67107ecd --- /dev/null +++ b/services/platform-service/src/modules/feedback/feedback.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/feedback/repository.ts b/services/platform-service/src/modules/feedback/repository.ts new file mode 100644 index 00000000..5f1cbf1e --- /dev/null +++ b/services/platform-service/src/modules/feedback/repository.ts @@ -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 { + 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 { + try { + const { resource } = await getContainer().item(id, productId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listFeedback( + productId: string, + query: QueryFeedbackInput +): Promise { + 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({ query: sql, parameters: params }) + .fetchAll(); + return resources; +} + +export async function updateFeedback( + id: string, + productId: string, + updates: UpdateFeedbackInput +): Promise { + 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 { + try { + await getContainer().item(id, productId).delete(); + return true; + } catch { + return false; + } +} + +export async function getFeedbackStats(productId: string): Promise> { + 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 = { 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; +} diff --git a/services/platform-service/src/modules/feedback/routes.ts b/services/platform-service/src/modules/feedback/routes.ts new file mode 100644 index 00000000..3e41856f --- /dev/null +++ b/services/platform-service/src/modules/feedback/routes.ts @@ -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 { + // ── 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); + }); +} diff --git a/services/platform-service/src/modules/feedback/types.ts b/services/platform-service/src/modules/feedback/types.ts new file mode 100644 index 00000000..cf993f0f --- /dev/null +++ b/services/platform-service/src/modules/feedback/types.ts @@ -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; +export type UpdateFeedbackInput = z.infer; +export type QueryFeedbackInput = z.infer; diff --git a/services/platform-service/src/modules/impersonation/impersonation.test.ts b/services/platform-service/src/modules/impersonation/impersonation.test.ts new file mode 100644 index 00000000..0d8a0e2e --- /dev/null +++ b/services/platform-service/src/modules/impersonation/impersonation.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/impersonation/repository.ts b/services/platform-service/src/modules/impersonation/repository.ts new file mode 100644 index 00000000..3a876b8d --- /dev/null +++ b/services/platform-service/src/modules/impersonation/repository.ts @@ -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 { + 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 { + try { + const { resource } = await getContainer() + .item(sessionId, productId) + .read(); + 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 { + const { resources } = await getContainer() + .items.query({ + 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 { + const { resources } = await getContainer() + .items.query({ + 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; +} diff --git a/services/platform-service/src/modules/impersonation/routes.ts b/services/platform-service/src/modules/impersonation/routes.ts new file mode 100644 index 00000000..9d7aaaaf --- /dev/null +++ b/services/platform-service/src/modules/impersonation/routes.ts @@ -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 { + // ── 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); + }); +} diff --git a/services/platform-service/src/modules/impersonation/types.ts b/services/platform-service/src/modules/impersonation/types.ts new file mode 100644 index 00000000..2d36a4f3 --- /dev/null +++ b/services/platform-service/src/modules/impersonation/types.ts @@ -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; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 1d10b97f..07e39cd5 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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 });