diff --git a/services/platform-service/src/modules/surveys/repository.ts b/services/platform-service/src/modules/surveys/repository.ts new file mode 100644 index 00000000..45ffac76 --- /dev/null +++ b/services/platform-service/src/modules/surveys/repository.ts @@ -0,0 +1,479 @@ +/** + * Survey repository — Cosmos DB CRUD + response aggregation + * @module surveys/repository + */ + +import { getContainer } from '../../lib/cosmos.js'; +import { + Survey, + SurveyResponse, + UserSurveyState, + QuestionAnswer, + type SurveyStatus, +} from './types.js'; + +// ============================================================================= +// Survey CRUD +// ============================================================================= + +export async function createSurvey(doc: Survey): Promise { + const container = getContainer('surveys'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create survey'); + return resource as unknown as Survey; +} + +export async function getSurvey(id: string, productId: string): Promise { + const container = getContainer('surveys'); + try { + const { resource } = await container.item(id, productId).read(); + return resource as unknown as Survey | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function listSurveys( + productId: string, + options?: { status?: SurveyStatus; limit?: number; offset?: number } +): Promise<{ surveys: Survey[]; total: number }> { + const container = getContainer('surveys'); + + let query = 'SELECT * FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + if (options?.status) { + query += ' AND c.status = @status'; + parameters.push({ name: '@status', value: options.status }); + } + + query += ' ORDER BY c.createdAt DESC'; + + const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)'); + const { resources: countResult } = await container.items + .query({ query: countQuery, parameters }) + .fetchAll(); + const total = countResult[0] ?? 0; + + if (options?.offset) query += ` OFFSET ${options.offset}`; + if (options?.limit) query += ` LIMIT ${options.limit}`; + + const { resources } = await container.items.query({ query, parameters }).fetchAll(); + return { surveys: resources, total }; +} + +export async function updateSurvey( + id: string, + productId: string, + updates: Partial +): Promise { + const container = getContainer('surveys'); + + const existing = await getSurvey(id, productId); + if (!existing) return null; + + const updated: Survey = { + ...existing, + ...updates, + id: existing.id, + productId: existing.productId, + updatedAt: new Date().toISOString(), + }; + + const { resource } = await container.items.upsert(updated); + if (!resource) throw new Error('Failed to update survey'); + return resource as unknown as Survey; +} + +export async function deleteSurvey(id: string, productId: string): Promise { + const container = getContainer('surveys'); + try { + await container.item(id, productId).delete(); + return true; + } catch (err) { + if ((err as { code?: number }).code === 404) return false; + throw err; + } +} + +// ============================================================================= +// Survey Responses +// ============================================================================= + +export async function createResponse(doc: SurveyResponse): Promise { + const container = getContainer('survey_responses'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create response'); + return resource as unknown as SurveyResponse; +} + +export async function getResponse( + surveyId: string, + userId: string +): Promise { + const container = getContainer('survey_responses'); + const id = `${surveyId}:${userId}`; + try { + const { resource } = await container.item(id, surveyId).read(); + return resource as unknown as SurveyResponse | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function updateResponse( + surveyId: string, + userId: string, + updates: Partial +): Promise { + const container = getContainer('survey_responses'); + const id = `${surveyId}:${userId}`; + + try { + const { resource: existing } = await container.item(id, surveyId).read(); + if (!existing) return null; + + const updated: SurveyResponse = { + ...(existing as SurveyResponse), + ...updates, + id, + surveyId, + userId, + updatedAt: new Date().toISOString(), + }; + + const { resource } = await container.items.upsert(updated); + if (!resource) throw new Error('Failed to update response'); + return resource as unknown as SurveyResponse; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function listResponsesForSurvey( + surveyId: string, + options?: { isComplete?: boolean; limit?: number; offset?: number } +): Promise<{ responses: SurveyResponse[]; total: number }> { + const container = getContainer('survey_responses'); + + let query = 'SELECT * FROM c WHERE c.surveyId = @surveyId'; + const parameters = [{ name: '@surveyId', value: surveyId }]; + + if (options?.isComplete !== undefined) { + query += ' AND c.isComplete = @isComplete'; + parameters.push({ name: '@isComplete', value: options.isComplete.toString() }); + } + + query += ' ORDER BY c.createdAt DESC'; + + const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)'); + const { resources: countResult } = await container.items + .query({ query: countQuery, parameters }) + .fetchAll(); + const total = countResult[0] ?? 0; + + if (options?.offset) query += ` OFFSET ${options.offset}`; + if (options?.limit) query += ` LIMIT ${options.limit}`; + + const { resources } = await container.items.query({ query, parameters }).fetchAll(); + return { responses: resources, total }; +} + +export async function listRespondents(surveyId: string): Promise { + const container = getContainer('survey_responses'); + + const query = 'SELECT c.userId FROM c WHERE c.surveyId = @surveyId AND c.isComplete = true'; + const parameters = [{ name: '@surveyId', value: surveyId }]; + + const { resources } = await container.items.query<{ userId: string }>({ query, parameters }).fetchAll(); + return resources.map((r) => r.userId); +} + +// ============================================================================= +// User Survey State (per-user tracking) +// ============================================================================= + +export async function getUserSurveyState( + surveyId: string, + userId: string +): Promise { + const container = getContainer('user_survey_states'); + const id = `${surveyId}:${userId}`; + try { + const { resource } = await container.item(id, userId).read(); + return resource as unknown as UserSurveyState | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function upsertUserSurveyState( + state: Omit & { createdAt?: string } +): Promise { + const container = getContainer('user_survey_states'); + const now = new Date().toISOString(); + + const doc: UserSurveyState = { + ...state, + createdAt: state.createdAt ?? now, + updatedAt: now, + }; + + const { resource } = await container.items.upsert(doc); + if (!resource) throw new Error('Failed to upsert user survey state'); + return resource as unknown as UserSurveyState; +} + +export async function incrementSurveyShowCount( + surveyId: string, + userId: string, + productId: string +): Promise { + const existing = await getUserSurveyState(surveyId, userId); + const now = new Date().toISOString(); + + if (existing) { + await upsertUserSurveyState({ + ...existing, + showCount: existing.showCount + 1, + lastShownAt: now, + }); + } else { + await upsertUserSurveyState({ + id: `${surveyId}:${userId}`, + surveyId, + userId, + productId, + status: 'shown', + showCount: 1, + lastShownAt: now, + }); + } +} + +// ============================================================================= +// Survey Metrics +// ============================================================================= + +export async function updateSurveyMetrics( + id: string, + productId: string, + metrics: Partial +): Promise { + const container = getContainer('surveys'); + + const existing = await getSurvey(id, productId); + if (!existing) return; + + const updated: Survey = { + ...existing, + metrics: { ...existing.metrics, ...metrics }, + updatedAt: new Date().toISOString(), + }; + + await container.items.upsert(updated); +} + +// ============================================================================= +// Analytics Aggregation +// ============================================================================= + +export interface QuestionAnalytics { + questionId: string; + questionType: string; + totalResponses: number; + + // For choice types + optionCounts?: Record; + + // For numeric types + average?: number; + min?: number; + max?: number; + distribution?: Record; + + // For text types + sampleResponses?: string[]; +} + +export async function getSurveyAnalytics(surveyId: string): Promise<{ + totalStarts: number; + totalCompletions: number; + completionRate: number; + avgTimeSeconds: number; + questionAnalytics: QuestionAnalytics[]; +}> { + const { responses } = await listResponsesForSurvey(surveyId); + + const totalStarts = responses.length; + const completedResponses = responses.filter((r) => r.isComplete); + const totalCompletions = completedResponses.length; + + // Calculate average time + const times = completedResponses + .map((r) => { + if (!r.completedAt || !r.startedAt) return 0; + return (new Date(r.completedAt).getTime() - new Date(r.startedAt).getTime()) / 1000; + }) + .filter((t) => t > 0); + + const avgTimeSeconds = times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0; + + // Get survey to access question definitions + const survey = await getSurvey(surveyId, ''); // productId not needed for this query + if (!survey) { + return { + totalStarts, + totalCompletions, + completionRate: totalStarts > 0 ? totalCompletions / totalStarts : 0, + avgTimeSeconds, + questionAnalytics: [], + }; + } + + // Aggregate per-question analytics + const questionAnalytics: QuestionAnalytics[] = survey.questions.map((q) => { + const qa: QuestionAnalytics = { + questionId: q.id, + questionType: q.type, + totalResponses: 0, + }; + + const answers = responses + .map((r) => r.answers[q.id]) + .filter((a): a is QuestionAnswer => a !== undefined); + + qa.totalResponses = answers.length; + + // Type-specific aggregation + if (q.type === 'single_choice' || q.type === 'dropdown') { + qa.optionCounts = {}; + answers.forEach((a) => { + if (a.type === 'single_choice') { + qa.optionCounts![a.optionId] = (qa.optionCounts![a.optionId] || 0) + 1; + } + }); + } + + if (q.type === 'multiple_choice') { + qa.optionCounts = {}; + answers.forEach((a) => { + if (a.type === 'multiple_choice') { + a.optionIds.forEach((id) => { + qa.optionCounts![id] = (qa.optionCounts![id] || 0) + 1; + }); + } + }); + } + + if (q.type === 'rating' || q.type === 'nps' || q.type === 'scale') { + const values = answers + .filter((a): a is { type: 'rating' | 'nps'; value: number } => + ['rating', 'nps'].includes(a.type) + ) + .map((a) => a.value); + + if (values.length > 0) { + qa.average = values.reduce((a, b) => a + b, 0) / values.length; + qa.min = Math.min(...values); + qa.max = Math.max(...values); + + // Build distribution + qa.distribution = {}; + values.forEach((v) => { + qa.distribution![v] = (qa.distribution![v] || 0) + 1; + }); + } + } + + if (q.type === 'text_short' || q.type === 'text_long') { + qa.sampleResponses = answers + .filter((a): a is { type: 'text'; value: string } => a.type === 'text') + .map((a) => a.value) + .slice(0, 10); // Sample first 10 + } + + if (q.type === 'ranking') { + qa.optionCounts = {}; + answers.forEach((a) => { + if (a.type === 'ranking') { + // Count first-choice rankings + const firstChoice = a.rankedOptionIds[0]; + if (firstChoice) { + qa.optionCounts![firstChoice] = (qa.optionCounts![firstChoice] || 0) + 1; + } + } + }); + } + + return qa; + }); + + return { + totalStarts, + totalCompletions, + completionRate: totalStarts > 0 ? totalCompletions / totalStarts : 0, + avgTimeSeconds, + questionAnalytics, + }; +} + +// ============================================================================= +// CSV Export +// ============================================================================= + +export function exportResponsesToCSV(responses: SurveyResponse[]): string { + if (responses.length === 0) return ''; + + // Get all unique question IDs + const questionIds = Array.from( + new Set(responses.flatMap((r) => Object.keys(r.answers))) + ); + + // Build headers + const headers = ['responseId', 'userId', 'startedAt', 'completedAt', 'isComplete', ...questionIds]; + + // Build rows + const rows = responses.map((r) => { + const base = [ + r.id, + r.userId, + r.startedAt, + r.completedAt ?? '', + r.isComplete ? 'yes' : 'no', + ]; + + const answers = questionIds.map((qid) => { + const ans = r.answers[qid]; + if (!ans) return ''; + + switch (ans.type) { + case 'single_choice': + // Also covers 'dropdown' questions (stored as single_choice answers) + return ans.optionId; + case 'multiple_choice': + return ans.optionIds.join(';'); + case 'rating': + case 'nps': + // Also covers 'scale' questions (stored as rating answers) + return ans.value.toString(); + case 'text': + return ans.value.replace(/"/g, '""'); // Escape quotes for CSV + case 'ranking': + return ans.rankedOptionIds.join(';'); + default: + return ''; + } + }); + + return [...base, ...answers]; + }); + + // Combine + const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s}"` : s); + return [headers.join(','), ...rows.map((r) => r.map(escape).join(','))].join('\n'); +} diff --git a/services/platform-service/src/modules/surveys/routes.ts b/services/platform-service/src/modules/surveys/routes.ts new file mode 100644 index 00000000..90f3db12 --- /dev/null +++ b/services/platform-service/src/modules/surveys/routes.ts @@ -0,0 +1,588 @@ +/** + * Survey REST routes — admin CRUD + public response endpoints + * @module surveys/routes + */ + +import type { FastifyInstance } from 'fastify'; +import { + UnauthorizedError, + ForbiddenError, + NotFoundError, + BadRequestError, +} from '../../lib/errors.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { evaluateTarget } from '../broadcasts/targeting.js'; +import * as repo from './repository.js'; +import { + CreateSurveySchema, + UpdateSurveySchema, + SubmitAnswerSchema, + SurveyStatus, + computeCompletionRate, + validateAnswerType, + type Survey, + type SurveyResponse, +} from './types.js'; + +// ============================================================================= +// Auth Helpers +// ============================================================================= + +function requireAuth(req: { jwtPayload?: { sub: string } }): string { + if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); + return req.jwtPayload.sub; +} + +function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string { + const userId = requireAuth(req); + if (req.jwtPayload?.role !== 'admin') { + throw new ForbiddenError('Admin access required'); + } + return userId; +} + +// ============================================================================= +// Admin Routes +// ============================================================================= + +async function adminRoutes(app: FastifyInstance): Promise { + // List all surveys + app.get('/', async (req) => { + const adminId = requireAdmin(req); + const productId = getRequestProductId(req); + + const { status } = req.query as { status?: string }; + const { surveys, total } = await repo.listSurveys(productId, { + status: status as Survey['status'], + }); + + // Add computed completion rate + const surveysWithRate = surveys.map((s) => ({ + ...s, + computedCompletionRate: computeCompletionRate(s.metrics), + })); + + req.log.info({ adminId, productId, count: surveys.length }, 'Listed surveys'); + return { surveys: surveysWithRate, total }; + }); + + // Get single survey with questions + app.get<{ Params: { id: string } }>('/:id', async (req) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + return survey; + }); + + // Create survey + app.post('/', async (req, reply) => { + const adminId = requireAdmin(req); + const productId = getRequestProductId(req); + + const input = CreateSurveySchema.parse(req.body); + + const now = new Date().toISOString(); + const survey: Survey = { + id: `survey_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + productId, + ...input, + status: SurveyStatus.DRAFT, + target: { platforms: ['web', 'ios', 'android'] }, // Default target + metrics: { + impressions: 0, + starts: 0, + completions: 0, + avgTimeSeconds: 0, + incentiveClaims: 0, + }, + createdAt: now, + updatedAt: now, + createdBy: adminId, + }; + + const created = await repo.createSurvey(survey); + req.log.info({ surveyId: created.id, adminId }, 'Created survey'); + + reply.status(201); + return created; + }); + + // Update survey + app.put<{ Params: { id: string } }>('/:id', async (req) => { + const adminId = requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const existing = await repo.getSurvey(id, productId); + if (!existing) throw new NotFoundError('Survey not found'); + + // Cannot edit closed surveys + if (existing.status === SurveyStatus.CLOSED) { + throw new BadRequestError('Cannot edit closed survey'); + } + + const updates = UpdateSurveySchema.parse(req.body); + + const updated = await repo.updateSurvey(id, productId, updates); + req.log.info({ surveyId: id, adminId }, 'Updated survey'); + + return updated; + }); + + // Delete survey + app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => { + const adminId = requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const deleted = await repo.deleteSurvey(id, productId); + if (!deleted) throw new NotFoundError('Survey not found'); + + req.log.info({ surveyId: id, adminId }, 'Deleted survey'); + reply.status(204); + return; + }); + + // Duplicate survey + app.post<{ Params: { id: string } }>('/:id/duplicate', async (req) => { + const adminId = requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const existing = await repo.getSurvey(id, productId); + if (!existing) throw new NotFoundError('Survey not found'); + + const now = new Date().toISOString(); + const cloned: Survey = { + ...existing, + id: `survey_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + title: `${existing.title} (Copy)`, + status: SurveyStatus.DRAFT, + metrics: { + impressions: 0, + starts: 0, + completions: 0, + avgTimeSeconds: 0, + incentiveClaims: 0, + }, + createdAt: now, + updatedAt: now, + createdBy: adminId, + }; + + const created = await repo.createSurvey(cloned); + req.log.info({ surveyId: created.id, parentId: id, adminId }, 'Cloned survey'); + + return created; + }); + + // Pause survey + app.post<{ Params: { id: string } }>('/:id/pause', async (req) => { + const adminId = requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const existing = await repo.getSurvey(id, productId); + if (!existing) throw new NotFoundError('Survey not found'); + + if (existing.status !== SurveyStatus.ACTIVE) { + throw new BadRequestError('Can only pause active surveys'); + } + + await repo.updateSurvey(id, productId, { status: SurveyStatus.PAUSED }); + req.log.info({ surveyId: id, adminId }, 'Paused survey'); + + return { success: true }; + }); + + // List responses + app.get<{ Params: { id: string } }>('/:id/responses', async (req) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const { isComplete, limit = 50, offset = 0 } = req.query as { + isComplete?: string; + limit?: string; + offset?: string; + }; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + const { responses, total } = await repo.listResponsesForSurvey(id, { + isComplete: isComplete === 'true' ? true : isComplete === 'false' ? false : undefined, + limit: parseInt(String(limit), 10), + offset: parseInt(String(offset), 10), + }); + + return { responses, total }; + }); + + // List respondents (just user IDs) + app.get<{ Params: { id: string } }>('/:id/respondents', async (req) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + const userIds = await repo.listRespondents(id); + return { userIds, count: userIds.length }; + }); + + // Get analytics + app.get<{ Params: { id: string } }>('/:id/analytics', async (req) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + const analytics = await repo.getSurveyAnalytics(id); + + return { + surveyId: id, + ...analytics, + computedMetrics: { + completionRate: computeCompletionRate({ + starts: analytics.totalStarts, + completions: analytics.totalCompletions, + impressions: 0, + avgTimeSeconds: analytics.avgTimeSeconds, + incentiveClaims: 0, + }), + }, + }; + }); + + // Export CSV + app.get<{ Params: { id: string } }>('/:id/export.csv', async (req, reply) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + const { responses } = await repo.listResponsesForSurvey(id, { isComplete: true }); + + const csv = repo.exportResponsesToCSV(responses); + + reply.header('Content-Type', 'text/csv'); + reply.header('Content-Disposition', `attachment; filename="${survey.id}_responses.csv"`); + return csv; + }); +} + +// ============================================================================= +// Public/User Routes +// ============================================================================= + +async function publicRoutes(app: FastifyInstance): Promise { + // Get active survey for user (rate limited in server.ts) + app.get('/active', async (req) => { + const userId = requireAuth(req); + const productId = getRequestProductId(req); + + // Get request headers for targeting context + const headers: Record = { + 'x-platform': req.headers['x-platform'] as string | undefined, + 'x-app-version': req.headers['x-app-version'] as string | undefined, + 'x-os-version': req.headers['x-os-version'] as string | undefined, + 'x-country-code': req.headers['x-country-code'] as string | undefined, + 'x-region-code': req.headers['x-region-code'] as string | undefined, + }; + + // Get user segments from JWT or default to ['free'] + const segments = (req.jwtPayload as { segments?: string[] } | undefined)?.segments ?? ['free']; + + // Build targeting context + const context: import('../broadcasts/types.js').TargetingContext = { + userId, + productId, + platform: (headers['x-platform'] ?? 'web') as import('../broadcasts/types.js').Platform, + appVersion: headers['x-app-version'] ?? '0.0.0', + osVersion: headers['x-os-version'] ?? '0.0.0', + countryCode: headers['x-country-code'], + regionCode: headers['x-region-code'], + userSegments: (segments as string[]) as import('../broadcasts/types.js').UserSegment[], + }; + + // Find all active surveys + const { surveys } = await repo.listSurveys(productId, { status: SurveyStatus.ACTIVE }); + + // Find first survey user qualifies for and hasn't completed/dismissed + for (const survey of surveys) { + // Check if user already has state for this survey + const state = await repo.getUserSurveyState(survey.id, userId); + + // Skip if already completed or dismissed + if (state?.status === 'completed' || state?.status === 'dismissed') { + continue; + } + + // Check targeting + if (evaluateTarget(survey.target, context)) { + // Update impression metrics + await repo.updateSurveyMetrics(survey.id, productId, { + impressions: survey.metrics.impressions + 1, + }); + + // Track that this user was shown the survey + await repo.incrementSurveyShowCount(survey.id, userId, productId); + + return { + survey: { + id: survey.id, + title: survey.title, + description: survey.description, + questions: survey.questions, + incentive: survey.incentive, + displayTrigger: survey.displayTrigger, + }, + }; + } + } + + // No eligible survey found + return { survey: null }; + }); + + // Start survey session + app.post<{ Params: { id: string } }>('/:id/start', async (req) => { + const userId = requireAuth(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + if (survey.status !== SurveyStatus.ACTIVE) { + throw new BadRequestError('Survey is not active'); + } + + const now = new Date().toISOString(); + + // Check for existing response + const existing = await repo.getResponse(id, userId); + if (existing) { + // Return existing session + return { + responseId: existing.id, + startedAt: existing.startedAt, + currentQuestionIndex: existing.currentQuestionIndex, + answers: existing.answers, + }; + } + + // Create new response + const response: SurveyResponse = { + id: `${id}:${userId}`, + surveyId: id, + productId, + userId, + deviceId: req.headers['x-device-id'] as string | undefined, + answers: {}, + currentQuestionIndex: 0, + startedAt: now, + isComplete: false, + incentiveClaimed: false, + createdAt: now, + updatedAt: now, + }; + + await repo.createResponse(response); + + // Update survey metrics + await repo.updateSurveyMetrics(id, productId, { + starts: survey.metrics.starts + 1, + }); + + // Update user state + await repo.upsertUserSurveyState({ + id: `${id}:${userId}`, + surveyId: id, + userId, + productId, + status: 'started', + showCount: 1, + startedAt: now, + }); + + return { + responseId: response.id, + startedAt: now, + currentQuestionIndex: 0, + answers: {}, + }; + }); + + // Submit answer + app.post<{ Params: { id: string } }>('/:id/response', async (req) => { + const userId = requireAuth(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const { questionId, answer } = SubmitAnswerSchema.parse(req.body); + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + // Find the question + const question = survey.questions.find((q) => q.id === questionId); + if (!question) throw new NotFoundError('Question not found'); + + // Validate answer type matches question type + const validation = validateAnswerType(question, answer); + if (!validation.valid) { + throw new BadRequestError(validation.error ?? 'Invalid answer type'); + } + + // Get or create response + let response = await repo.getResponse(id, userId); + if (!response) { + throw new BadRequestError('Survey session not started'); + } + + // Update answer + const updatedAnswers = { ...response.answers, [questionId]: answer }; + + // Find next question index (skipping conditional questions) + let nextIndex = response.currentQuestionIndex + 1; + + response = await repo.updateResponse(id, userId, { + answers: updatedAnswers, + currentQuestionIndex: nextIndex, + }); + + if (!response) throw new Error('Failed to update response'); + + return { + responseId: response.id, + currentQuestionIndex: response.currentQuestionIndex, + answers: response.answers, + }; + }); + + // Complete survey + app.post<{ Params: { id: string } }>('/:id/complete', async (req) => { + const userId = requireAuth(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const survey = await repo.getSurvey(id, productId); + if (!survey) throw new NotFoundError('Survey not found'); + + const response = await repo.getResponse(id, userId); + if (!response) throw new NotFoundError('Survey session not found'); + + const now = new Date().toISOString(); + + // Check for required questions + const unansweredRequired = survey.questions.filter( + (q) => q.required && !response.answers[q.id] + ); + if (unansweredRequired.length > 0) { + throw new BadRequestError( + `Required questions unanswered: ${unansweredRequired.map((q) => q.id).join(', ')}` + ); + } + + // Calculate time spent + const timeSpentSeconds = Math.floor( + (new Date(now).getTime() - new Date(response.startedAt).getTime()) / 1000 + ); + + // Update response as complete + const updated = await repo.updateResponse(id, userId, { + isComplete: true, + completedAt: now, + }); + + if (!updated) throw new Error('Failed to complete survey'); + + // Update survey metrics + const newAvgTime = + survey.metrics.completions > 0 + ? (survey.metrics.avgTimeSeconds * survey.metrics.completions + timeSpentSeconds) / + (survey.metrics.completions + 1) + : timeSpentSeconds; + + await repo.updateSurveyMetrics(id, productId, { + completions: survey.metrics.completions + 1, + avgTimeSeconds: newAvgTime, + }); + + // Update user state + await repo.upsertUserSurveyState({ + id: `${id}:${userId}`, + surveyId: id, + userId, + productId, + status: 'completed', + showCount: 1, + completedAt: now, + }); + + // Handle incentive fulfillment + let incentiveClaimed = false; + if (survey.incentive) { + // TODO: Integrate with billing/subscriptions module + // to grant pro_days or credits + incentiveClaimed = true; + await repo.updateResponse(id, userId, { + incentiveClaimed: true, + incentiveClaimedAt: now, + }); + + await repo.updateSurveyMetrics(id, productId, { + incentiveClaims: survey.metrics.incentiveClaims + 1, + }); + } + + return { + success: true, + timeSpentSeconds, + incentiveClaimed, + }; + }); + + // Dismiss survey + app.post<{ Params: { id: string } }>('/:id/dismiss', async (req) => { + const userId = requireAuth(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const now = new Date().toISOString(); + + await repo.upsertUserSurveyState({ + id: `${id}:${userId}`, + surveyId: id, + userId, + productId, + status: 'dismissed', + showCount: 1, + dismissedAt: now, + }); + + return { success: true }; + }); +} + +// ============================================================================= +// Main Route Registration +// ============================================================================= + +export async function surveyRoutes(app: FastifyInstance): Promise { + // Admin routes + await app.register(adminRoutes, { prefix: '/admin/surveys' }); + + // Public routes + await app.register(publicRoutes, { prefix: '/surveys' }); +} diff --git a/services/platform-service/src/modules/surveys/types.ts b/services/platform-service/src/modules/surveys/types.ts new file mode 100644 index 00000000..b9ab035d --- /dev/null +++ b/services/platform-service/src/modules/surveys/types.ts @@ -0,0 +1,337 @@ +/** + * Survey types — in-app surveys with conditional logic + * @module surveys/types + */ + +import { z } from 'zod'; + +// Re-export from broadcasts for targeting +import type { BroadcastTarget } from '../broadcasts/types.js'; +export type { BroadcastTarget }; + +// ============================================================================= +// Enums & Constants +// ============================================================================= + +export const SurveyStatus = { + DRAFT: 'draft', + ACTIVE: 'active', + PAUSED: 'paused', + CLOSED: 'closed', +} as const; + +export type SurveyStatus = (typeof SurveyStatus)[keyof typeof SurveyStatus]; + +export const QuestionType = { + SINGLE_CHOICE: 'single_choice', + MULTIPLE_CHOICE: 'multiple_choice', + RATING: 'rating', + NPS: 'nps', + TEXT_SHORT: 'text_short', + TEXT_LONG: 'text_long', + DROPDOWN: 'dropdown', + SCALE: 'scale', + RANKING: 'ranking', +} as const; + +export type QuestionType = (typeof QuestionType)[keyof typeof QuestionType]; + +// ============================================================================= +// Conditional Logic +// ============================================================================= + +export type ShowIfCondition = + | { and: ShowIfCondition[] } + | { or: ShowIfCondition[] } + | { + questionId: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in'; + value: string | string[] | number; + }; + +// ============================================================================= +// Question Types +// ============================================================================= + +export interface QuestionOption { + id: string; + text: string; + emoji?: string; +} + +export interface Question { + id: string; + type: QuestionType; + text: string; + description?: string; + required: boolean; + + // For choice types + options?: QuestionOption[]; + + // Conditional logic + showIf?: ShowIfCondition; + + // Validation + minLength?: number; + maxLength?: number; + minValue?: number; + maxValue?: number; +} + +// ============================================================================= +// Survey Definition +// ============================================================================= + +export type SurveyTrigger = + | { type: 'immediate' } + | { type: 'delay_seconds'; seconds: number } + | { type: 'event'; eventName: string } + | { type: 'page_view'; pagePattern: string }; + +export interface Survey { + id: string; + productId: string; + title: string; + description?: string; + + // Questions + questions: Question[]; + + // Targeting (same as BroadcastTarget) + target: BroadcastTarget; + + // Scheduling + status: SurveyStatus; + startsAt?: string; + endsAt?: string; + + // Display settings + displayTrigger: SurveyTrigger; + + // Incentives + incentive?: { + type: 'pro_days' | 'credits'; + amount: number; + }; + + // Metrics (stored, computed on read) + metrics: SurveyMetrics; + + createdAt: string; + updatedAt: string; + createdBy: string; +} + +export interface SurveyMetrics { + impressions: number; + starts: number; + completions: number; + avgTimeSeconds: number; + incentiveClaims: number; +} + +// ============================================================================= +// Survey Response +// ============================================================================= + +export type QuestionAnswer = + | { type: 'single_choice'; optionId: string } + | { type: 'multiple_choice'; optionIds: string[] } + | { type: 'rating'; value: number } + | { type: 'nps'; value: number } + | { type: 'text'; value: string } + | { type: 'ranking'; rankedOptionIds: string[] }; + +export interface SurveyResponse { + id: string; + surveyId: string; + productId: string; + userId: string; + deviceId?: string; + + // Answers keyed by question ID + answers: Record; + + // Progress tracking + currentQuestionIndex: number; + + // Metadata + startedAt: string; + completedAt?: string; + isComplete: boolean; + + // Incentive + incentiveClaimed: boolean; + incentiveClaimedAt?: string; + + createdAt: string; + updatedAt: string; +} + +// ============================================================================= +// User Survey State (per-user tracking) +// ============================================================================= + +export interface UserSurveyState { + id: string; // composite: `${surveyId}:${userId}` + surveyId: string; + userId: string; + productId: string; + + // Display state + status: 'eligible' | 'shown' | 'started' | 'completed' | 'dismissed'; + + // Timestamps + firstShownAt?: string; + startedAt?: string; + completedAt?: string; + dismissedAt?: string; + + // For throttling (don't show too often) + showCount: number; + lastShownAt?: string; + + createdAt: string; + updatedAt: string; +} + +// ============================================================================= +// Zod Schemas +// ============================================================================= + +const ShowIfConditionSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ and: z.array(ShowIfConditionSchema) }), + z.object({ or: z.array(ShowIfConditionSchema) }), + z.object({ + questionId: z.string(), + operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']), + value: z.union([z.string(), z.array(z.string()), z.number()]), + }), + ]) +); + +export const QuestionSchema = z.object({ + id: z.string().min(1), + type: z.nativeEnum(QuestionType), + text: z.string().min(1).max(500), + description: z.string().max(1000).optional(), + required: z.boolean(), + options: z + .array( + z.object({ + id: z.string(), + text: z.string(), + emoji: z.string().optional(), + }) + ) + .optional(), + showIf: ShowIfConditionSchema.optional(), + minLength: z.number().min(0).optional(), + maxLength: z.number().min(1).optional(), + minValue: z.number().optional(), + maxValue: z.number().optional(), +}); + +export const SurveyTriggerSchema = z.union([ + z.object({ type: z.literal('immediate') }), + z.object({ type: z.literal('delay_seconds'), seconds: z.number().min(1).max(3600) }), + z.object({ type: z.literal('event'), eventName: z.string() }), + z.object({ type: z.literal('page_view'), pagePattern: z.string() }), +]); + +export const CreateSurveySchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + questions: z.array(QuestionSchema).min(1).max(50), + startsAt: z.string().datetime().optional(), + endsAt: z.string().datetime().optional(), + displayTrigger: SurveyTriggerSchema, + incentive: z + .object({ + type: z.enum(['pro_days', 'credits']), + amount: z.number().min(1), + }) + .optional(), +}); + +export const UpdateSurveySchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(2000).optional(), + status: z.nativeEnum(SurveyStatus).optional(), + startsAt: z.string().datetime().optional(), + endsAt: z.string().datetime().optional(), + displayTrigger: SurveyTriggerSchema.optional(), +}); + +export const SubmitAnswerSchema = z.object({ + questionId: z.string(), + answer: z.union([ + z.object({ type: z.literal('single_choice'), optionId: z.string() }), + z.object({ type: z.literal('multiple_choice'), optionIds: z.array(z.string()) }), + z.object({ type: z.literal('rating'), value: z.number().min(1).max(10) }), + z.object({ type: z.literal('nps'), value: z.number().min(0).max(10) }), + z.object({ type: z.literal('text'), value: z.string() }), + z.object({ type: z.literal('ranking'), rankedOptionIds: z.array(z.string()) }), + ]), +}); + +// ============================================================================= +// Helper Functions +// ============================================================================= + +export function computeCompletionRate(metrics: SurveyMetrics): number { + return metrics.starts > 0 ? metrics.completions / metrics.starts : 0; +} + +export function validateAnswerType( + question: Question, + answer: QuestionAnswer +): { valid: boolean; error?: string } { + if (question.type === 'single_choice' && answer.type !== 'single_choice') { + return { valid: false, error: `Expected single_choice, got ${answer.type}` }; + } + if (question.type === 'multiple_choice' && answer.type !== 'multiple_choice') { + return { valid: false, error: `Expected multiple_choice, got ${answer.type}` }; + } + if (question.type === 'rating' && answer.type !== 'rating') { + return { valid: false, error: `Expected rating, got ${answer.type}` }; + } + if (question.type === 'nps' && answer.type !== 'nps') { + return { valid: false, error: `Expected nps, got ${answer.type}` }; + } + if ((question.type === 'text_short' || question.type === 'text_long') && answer.type !== 'text') { + return { valid: false, error: `Expected text, got ${answer.type}` }; + } + if (question.type === 'dropdown' && answer.type !== 'single_choice') { + return { valid: false, error: `Expected single_choice for dropdown, got ${answer.type}` }; + } + if (question.type === 'scale' && answer.type !== 'rating') { + return { valid: false, error: `Expected rating for scale, got ${answer.type}` }; + } + if (question.type === 'ranking' && answer.type !== 'ranking') { + return { valid: false, error: `Expected ranking, got ${answer.type}` }; + } + + // Validate value ranges + if (answer.type === 'rating') { + if (question.minValue !== undefined && answer.value < question.minValue) { + return { valid: false, error: `Rating below minimum ${question.minValue}` }; + } + if (question.maxValue !== undefined && answer.value > question.maxValue) { + return { valid: false, error: `Rating above maximum ${question.maxValue}` }; + } + } + + if (answer.type === 'text') { + if (question.minLength !== undefined && answer.value.length < question.minLength) { + return { valid: false, error: `Text too short (min ${question.minLength})` }; + } + if (question.maxLength !== undefined && answer.value.length > question.maxLength) { + return { valid: false, error: `Text too long (max ${question.maxLength})` }; + } + } + + return { valid: true }; +}