From 0e880fd40dc19a63d26b63aae437085405302586 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 22:07:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(platform-service):=20shared=20onboarding?= =?UTF-8?q?=20analytics=20module=20(Phase=204.3)=20=E2=80=94=208=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New modules/onboarding/ with standard pattern (types → repository → routes): - POST /onboarding/step — track step completion (any auth) - POST /onboarding/complete — track onboarding completion (any auth) - GET /onboarding/funnel — funnel conversion rates (admin) - GET /onboarding/user/:userId — user progress (admin) - Cosmos containers: onboarding_events, onboarding_completions - Funnel: per-step unique users, conversion rates, avg duration - 8 tests: schema validation, funnel, date filter, user progress, empty product --- .../src/modules/onboarding/onboarding.test.ts | 170 ++++++++++++++++++ .../src/modules/onboarding/repository.ts | 131 ++++++++++++++ .../src/modules/onboarding/routes.ts | 103 +++++++++++ .../src/modules/onboarding/types.ts | 64 +++++++ services/platform-service/src/server.ts | 3 + 5 files changed, 471 insertions(+) create mode 100644 services/platform-service/src/modules/onboarding/onboarding.test.ts create mode 100644 services/platform-service/src/modules/onboarding/repository.ts create mode 100644 services/platform-service/src/modules/onboarding/routes.ts create mode 100644 services/platform-service/src/modules/onboarding/types.ts diff --git a/services/platform-service/src/modules/onboarding/onboarding.test.ts b/services/platform-service/src/modules/onboarding/onboarding.test.ts new file mode 100644 index 00000000..6fe36c75 --- /dev/null +++ b/services/platform-service/src/modules/onboarding/onboarding.test.ts @@ -0,0 +1,170 @@ +/** + * Onboarding analytics tests — step tracking, completion, funnel. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import * as repo from './repository.js'; +import { + TrackStepSchema, + TrackCompletionSchema, + type OnboardingEventDoc, + type OnboardingCompletionDoc, +} from './types.js'; + +const PRODUCT = 'onboard-test-product'; + +describe('onboarding analytics', () => { + beforeAll(async () => { + // Seed step events for 3 users across 3 steps + const steps: OnboardingEventDoc[] = [ + { + id: 'obs_1', + productId: PRODUCT, + userId: 'user-a', + stepName: 'welcome', + stepIndex: 0, + completedAt: '2026-03-19T10:00:00.000Z', + }, + { + id: 'obs_2', + productId: PRODUCT, + userId: 'user-a', + stepName: 'profile', + stepIndex: 1, + completedAt: '2026-03-19T10:01:00.000Z', + }, + { + id: 'obs_3', + productId: PRODUCT, + userId: 'user-a', + stepName: 'first_action', + stepIndex: 2, + completedAt: '2026-03-19T10:02:00.000Z', + }, + { + id: 'obs_4', + productId: PRODUCT, + userId: 'user-b', + stepName: 'welcome', + stepIndex: 0, + completedAt: '2026-03-19T10:00:00.000Z', + }, + { + id: 'obs_5', + productId: PRODUCT, + userId: 'user-b', + stepName: 'profile', + stepIndex: 1, + completedAt: '2026-03-19T10:01:00.000Z', + }, + { + id: 'obs_6', + productId: PRODUCT, + userId: 'user-c', + stepName: 'welcome', + stepIndex: 0, + completedAt: '2026-03-19T10:00:00.000Z', + }, + ]; + for (const s of steps) { + await repo.trackStep(s); + } + + // Seed completions for user-a only + const completion: OnboardingCompletionDoc = { + id: 'obc_1', + productId: PRODUCT, + userId: 'user-a', + completedAt: '2026-03-19T10:03:00.000Z', + totalSteps: 3, + durationMs: 180000, + }; + await repo.trackCompletion(completion); + }); + + it('TrackStepSchema validates required fields', () => { + const valid = TrackStepSchema.safeParse({ + userId: 'user-1', + stepName: 'welcome', + stepIndex: 0, + }); + expect(valid.success).toBe(true); + + const invalid = TrackStepSchema.safeParse({ + userId: '', + stepName: 'welcome', + stepIndex: 0, + }); + expect(invalid.success).toBe(false); + }); + + it('TrackCompletionSchema has correct defaults', () => { + const parsed = TrackCompletionSchema.parse({ userId: 'user-1' }); + expect(parsed.totalSteps).toBe(1); + expect(parsed.durationMs).toBeUndefined(); + }); + + it('getFunnel returns correct funnel data', async () => { + const funnel = await repo.getFunnel(PRODUCT); + + expect(funnel.productId).toBe(PRODUCT); + expect(funnel.totalStarted).toBe(3); // 3 unique users + expect(funnel.totalCompleted).toBe(1); // only user-a completed + expect(funnel.completionRate).toBeCloseTo(1 / 3, 2); + expect(funnel.avgDurationMs).toBe(180000); + + // Steps sorted by stepIndex + expect(funnel.steps.length).toBe(3); + expect(funnel.steps[0].stepName).toBe('welcome'); + expect(funnel.steps[0].uniqueUsers).toBe(3); + expect(funnel.steps[0].conversionRate).toBe(1.0); + + expect(funnel.steps[1].stepName).toBe('profile'); + expect(funnel.steps[1].uniqueUsers).toBe(2); + expect(funnel.steps[1].conversionRate).toBeCloseTo(2 / 3, 2); + + expect(funnel.steps[2].stepName).toBe('first_action'); + expect(funnel.steps[2].uniqueUsers).toBe(1); + expect(funnel.steps[2].conversionRate).toBeCloseTo(1 / 3, 2); + }); + + it('getFunnel with date filter narrows results', async () => { + const funnel = await repo.getFunnel( + PRODUCT, + '2026-03-19T10:01:00.000Z', + '2026-03-19T10:02:00.000Z' + ); + + // Only steps within that window + expect(funnel.totalStarted).toBeGreaterThanOrEqual(1); + }); + + it('getUserSteps returns steps for a specific user', async () => { + const steps = await repo.getUserSteps(PRODUCT, 'user-a'); + + expect(steps.length).toBe(3); + expect(steps[0].stepIndex).toBeLessThanOrEqual(steps[1].stepIndex); + }); + + it('getUserCompletion returns completion for a finished user', async () => { + const completion = await repo.getUserCompletion(PRODUCT, 'user-a'); + expect(completion).not.toBeNull(); + expect(completion!.totalSteps).toBe(3); + expect(completion!.durationMs).toBe(180000); + }); + + it('getUserCompletion returns null for incomplete user', async () => { + const completion = await repo.getUserCompletion(PRODUCT, 'user-c'); + expect(completion).toBeNull(); + }); + + it('getFunnel for unknown product returns zero counts', async () => { + const funnel = await repo.getFunnel('nonexistent-product'); + + expect(funnel.totalStarted).toBe(0); + expect(funnel.totalCompleted).toBe(0); + expect(funnel.completionRate).toBe(0); + expect(funnel.steps).toHaveLength(0); + expect(funnel.avgDurationMs).toBeUndefined(); + }); +}); diff --git a/services/platform-service/src/modules/onboarding/repository.ts b/services/platform-service/src/modules/onboarding/repository.ts new file mode 100644 index 00000000..b4c055ee --- /dev/null +++ b/services/platform-service/src/modules/onboarding/repository.ts @@ -0,0 +1,131 @@ +/** + * Onboarding repository — cloud-agnostic via @bytelyst/datastore. + */ + +import type { FilterMap } from '@bytelyst/datastore'; +import { getCollection } from '../../lib/datastore.js'; +import type { + OnboardingEventDoc, + OnboardingCompletionDoc, + OnboardingFunnel, + FunnelStep, +} from './types.js'; + +function stepCollection() { + return getCollection('onboarding_events', '/productId'); +} + +function completionCollection() { + return getCollection('onboarding_completions', '/productId'); +} + +// ── Step tracking ── + +export async function trackStep(doc: OnboardingEventDoc): Promise { + return stepCollection().upsert(doc); +} + +export async function trackCompletion( + doc: OnboardingCompletionDoc +): Promise { + return completionCollection().upsert(doc); +} + +// ── Funnel query ── + +export async function getFunnel( + productId: string, + from?: string, + to?: string +): Promise { + const stepFilter: FilterMap = { productId }; + if (from) stepFilter.completedAt = { ...((stepFilter.completedAt as object) ?? {}), $gte: from }; + if (to) stepFilter.completedAt = { ...((stepFilter.completedAt as object) ?? {}), $lte: to }; + + const steps = await stepCollection().findMany({ filter: stepFilter }); + + const completionFilter: FilterMap = { productId }; + if (from) + completionFilter.completedAt = { + ...((completionFilter.completedAt as object) ?? {}), + $gte: from, + }; + if (to) + completionFilter.completedAt = { + ...((completionFilter.completedAt as object) ?? {}), + $lte: to, + }; + + const completions = await completionCollection().findMany({ filter: completionFilter }); + + // Aggregate steps by stepName+stepIndex + const stepMap = new Map }>(); + const allUserIds = new Set(); + + for (const s of steps) { + allUserIds.add(s.userId); + const key = `${s.stepIndex}:${s.stepName}`; + const existing = stepMap.get(key); + if (existing) { + existing.userIds.add(s.userId); + } else { + stepMap.set(key, { + stepName: s.stepName, + stepIndex: s.stepIndex, + userIds: new Set([s.userId]), + }); + } + } + + const totalStarted = allUserIds.size; + const totalCompleted = new Set(completions.map(c => c.userId)).size; + + // Build funnel steps sorted by stepIndex + const funnelSteps: FunnelStep[] = Array.from(stepMap.values()) + .sort((a, b) => a.stepIndex - b.stepIndex) + .map(s => ({ + stepName: s.stepName, + stepIndex: s.stepIndex, + uniqueUsers: s.userIds.size, + conversionRate: totalStarted > 0 ? s.userIds.size / totalStarted : 0, + })); + + // Average duration from completions + const durations = completions.filter(c => c.durationMs != null).map(c => c.durationMs!); + const avgDurationMs = + durations.length > 0 + ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) + : undefined; + + return { + productId, + totalStarted, + totalCompleted, + completionRate: totalStarted > 0 ? totalCompleted / totalStarted : 0, + steps: funnelSteps, + avgDurationMs, + }; +} + +// ── User progress query ── + +export async function getUserSteps( + productId: string, + userId: string +): Promise { + return stepCollection().findMany({ + filter: { productId, userId }, + sort: { stepIndex: 1 }, + }); +} + +export async function getUserCompletion( + productId: string, + userId: string +): Promise { + const results = await completionCollection().findMany({ + filter: { productId, userId }, + limit: 1, + }); + return results[0] ?? null; +} diff --git a/services/platform-service/src/modules/onboarding/routes.ts b/services/platform-service/src/modules/onboarding/routes.ts new file mode 100644 index 00000000..96718438 --- /dev/null +++ b/services/platform-service/src/modules/onboarding/routes.ts @@ -0,0 +1,103 @@ +/** + * Onboarding analytics REST endpoints. + * + * POST /onboarding/step — track step completion (any auth) + * POST /onboarding/complete — track onboarding completion (any auth) + * GET /onboarding/funnel — funnel conversion rates (admin) + * GET /onboarding/user/:userId — user progress (admin) + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, UnauthorizedError } from '../../lib/errors.js'; +import * as repo from './repository.js'; +import { + TrackStepSchema, + TrackCompletionSchema, + type OnboardingEventDoc, + type OnboardingCompletionDoc, +} from './types.js'; + +function requireAdmin(req: { jwtPayload?: { role?: string } }): void { + if (req.jwtPayload?.role !== 'admin') { + throw new UnauthorizedError('Admin access required'); + } +} + +export async function onboardingRoutes(app: FastifyInstance) { + // ── Track step completion ────────────────────────────────── + app.post('/onboarding/step', async (req, reply) => { + const productId = getRequestProductId(req); + const parsed = TrackStepSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const now = new Date().toISOString(); + const doc: OnboardingEventDoc = { + id: `obs_${randomUUID()}`, + productId, + userId: parsed.data.userId, + stepName: parsed.data.stepName, + stepIndex: parsed.data.stepIndex, + completedAt: now, + metadata: parsed.data.metadata, + }; + + const created = await repo.trackStep(doc); + reply.code(201); + return created; + }); + + // ── Track onboarding completion ──────────────────────────── + app.post('/onboarding/complete', async (req, reply) => { + const productId = getRequestProductId(req); + const parsed = TrackCompletionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const now = new Date().toISOString(); + const doc: OnboardingCompletionDoc = { + id: `obc_${randomUUID()}`, + productId, + userId: parsed.data.userId, + completedAt: now, + totalSteps: parsed.data.totalSteps, + durationMs: parsed.data.durationMs, + }; + + const created = await repo.trackCompletion(doc); + reply.code(201); + return created; + }); + + // ── Admin: funnel query ──────────────────────────────────── + app.get('/onboarding/funnel', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { from, to } = req.query as { from?: string; to?: string }; + return repo.getFunnel(productId, from, to); + }); + + // ── Admin: user progress ─────────────────────────────────── + app.get('/onboarding/user/:userId', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { userId } = req.params as { userId: string }; + + const [steps, completion] = await Promise.all([ + repo.getUserSteps(productId, userId), + repo.getUserCompletion(productId, userId), + ]); + + return { + userId, + productId, + steps, + completed: !!completion, + completion, + }; + }); +} diff --git a/services/platform-service/src/modules/onboarding/types.ts b/services/platform-service/src/modules/onboarding/types.ts new file mode 100644 index 00000000..e6a67a9c --- /dev/null +++ b/services/platform-service/src/modules/onboarding/types.ts @@ -0,0 +1,64 @@ +/** + * Onboarding analytics types — track step completion and funnel conversion. + * + * Cosmos container: `onboarding_events` (partitioned by `/productId`) + */ + +import { z } from 'zod'; + +// ── Onboarding Event Doc ── + +export interface OnboardingEventDoc { + id: string; + productId: string; + userId: string; + stepName: string; + stepIndex: number; + completedAt: string; + metadata?: Record; +} + +export interface OnboardingCompletionDoc { + id: string; + productId: string; + userId: string; + completedAt: string; + totalSteps: number; + durationMs?: number; +} + +// ── Schemas ── + +export const TrackStepSchema = z.object({ + userId: z.string().min(1), + stepName: z.string().min(1).max(128), + stepIndex: z.number().int().min(0).max(100), + metadata: z.record(z.unknown()).optional(), +}); + +export const TrackCompletionSchema = z.object({ + userId: z.string().min(1), + totalSteps: z.number().int().min(1).max(100).optional().default(1), + durationMs: z.number().int().min(0).optional(), +}); + +export type TrackStepInput = z.infer; +export type TrackCompletionInput = z.infer; + +// ── Funnel types ── + +export interface FunnelStep { + stepName: string; + stepIndex: number; + uniqueUsers: number; + conversionRate: number; +} + +export interface OnboardingFunnel { + productId: string; + totalStarted: number; + totalCompleted: number; + completionRate: number; + steps: FunnelStep[]; + avgDurationMs?: number; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 88bab011..6cde47f6 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -89,6 +89,7 @@ import { changelogRoutes } from './modules/changelog/routes.js'; import { webhookRoutes } from './modules/webhooks/routes.js'; import { marketplaceRoutes } from './modules/marketplace/routes.js'; import { predictiveAnalyticsRoutes } from './modules/predictive-analytics/routes.js'; +import { onboardingRoutes } from './modules/onboarding/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; import type { JwtPayload } from './lib/request-context.js'; @@ -222,6 +223,8 @@ await app.register(webhookRoutes, { prefix: '/api' }); await app.register(marketplaceRoutes, { prefix: '/api' }); // Predictive Analytics (Churn & Health Scoring) await app.register(predictiveAnalyticsRoutes, { prefix: '/api' }); +// Onboarding analytics (Phase 4.3) +await app.register(onboardingRoutes, { prefix: '/api' }); // Broadcast Messaging & Surveys (see docs/roadmaps/not-started/platform_BROADCAST_SURVEY_ROADMAP.md) await app.register(broadcastRoutes, { prefix: '/api' }); await app.register(surveyRoutes, { prefix: '/api' });