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:
parent
d900df3dc8
commit
0e880fd40d
@ -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();
|
||||
});
|
||||
});
|
||||
131
services/platform-service/src/modules/onboarding/repository.ts
Normal file
131
services/platform-service/src/modules/onboarding/repository.ts
Normal 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;
|
||||
}
|
||||
103
services/platform-service/src/modules/onboarding/routes.ts
Normal file
103
services/platform-service/src/modules/onboarding/routes.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
64
services/platform-service/src/modules/onboarding/types.ts
Normal file
64
services/platform-service/src/modules/onboarding/types.ts
Normal 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;
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user