feat(platform-service): shared onboarding analytics module (Phase 4.3) — 8 tests

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
This commit is contained in:
saravanakumardb1 2026-03-19 22:07:03 -07:00
parent d900df3dc8
commit 0e880fd40d
5 changed files with 471 additions and 0 deletions

View File

@ -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();
});
});

View File

@ -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<OnboardingEventDoc>('onboarding_events', '/productId');
}
function completionCollection() {
return getCollection<OnboardingCompletionDoc>('onboarding_completions', '/productId');
}
// ── Step tracking ──
export async function trackStep(doc: OnboardingEventDoc): Promise<OnboardingEventDoc> {
return stepCollection().upsert(doc);
}
export async function trackCompletion(
doc: OnboardingCompletionDoc
): Promise<OnboardingCompletionDoc> {
return completionCollection().upsert(doc);
}
// ── Funnel query ──
export async function getFunnel(
productId: string,
from?: string,
to?: string
): Promise<OnboardingFunnel> {
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<string, { stepName: string; stepIndex: number; userIds: Set<string> }>();
const allUserIds = new Set<string>();
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<OnboardingEventDoc[]> {
return stepCollection().findMany({
filter: { productId, userId },
sort: { stepIndex: 1 },
});
}
export async function getUserCompletion(
productId: string,
userId: string
): Promise<OnboardingCompletionDoc | null> {
const results = await completionCollection().findMany({
filter: { productId, userId },
limit: 1,
});
return results[0] ?? null;
}

View File

@ -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,
};
});
}

View File

@ -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<string, unknown>;
}
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<typeof TrackStepSchema>;
export type TrackCompletionInput = z.infer<typeof TrackCompletionSchema>;
// ── 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;
}

View File

@ -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' });