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 { webhookRoutes } from './modules/webhooks/routes.js';
|
||||||
import { marketplaceRoutes } from './modules/marketplace/routes.js';
|
import { marketplaceRoutes } from './modules/marketplace/routes.js';
|
||||||
import { predictiveAnalyticsRoutes } from './modules/predictive-analytics/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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
import type { JwtPayload } from './lib/request-context.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' });
|
await app.register(marketplaceRoutes, { prefix: '/api' });
|
||||||
// Predictive Analytics (Churn & Health Scoring)
|
// Predictive Analytics (Churn & Health Scoring)
|
||||||
await app.register(predictiveAnalyticsRoutes, { prefix: '/api' });
|
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)
|
// Broadcast Messaging & Surveys (see docs/roadmaps/not-started/platform_BROADCAST_SURVEY_ROADMAP.md)
|
||||||
await app.register(broadcastRoutes, { prefix: '/api' });
|
await app.register(broadcastRoutes, { prefix: '/api' });
|
||||||
await app.register(surveyRoutes, { prefix: '/api' });
|
await app.register(surveyRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user