diff --git a/services/platform-service/src/modules/broadcasts/repository.ts b/services/platform-service/src/modules/broadcasts/repository.ts index 80771748..671c5504 100644 --- a/services/platform-service/src/modules/broadcasts/repository.ts +++ b/services/platform-service/src/modules/broadcasts/repository.ts @@ -9,7 +9,6 @@ import { BroadcastDelivery, InAppMessage, BroadcastRead, - TargetingContext, type BroadcastStatus, } from './types.js'; @@ -66,9 +65,7 @@ export async function listBroadcasts( query += ` LIMIT ${options.limit}`; } - const { resources } = await container.items - .query({ query, parameters }) - .fetchAll(); + const { resources } = await container.items.query({ query, parameters }).fetchAll(); return { broadcasts: resources, total }; } @@ -212,8 +209,9 @@ export async function listDeliveriesForBroadcast( .fetchAll(); const total = countResult[0] ?? 0; - if (options?.offset) query += ` OFFSET ${options.offset}`; - if (options?.limit) query += ` LIMIT ${options.limit}`; + if (options?.limit) { + query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`; + } const { resources } = await container.items .query({ query, parameters }) @@ -252,14 +250,12 @@ export async function getInAppMessagesForUser( } // Only show non-expired messages - query += " AND (c.expiresAt = null OR c.expiresAt > @now)"; + query += ' AND (c.expiresAt = null OR c.expiresAt > @now)'; parameters.push({ name: '@now', value: new Date().toISOString() }); query += ' ORDER BY c.createdAt DESC'; - const { resources } = await container.items - .query({ query, parameters }) - .fetchAll(); + const { resources } = await container.items.query({ query, parameters }).fetchAll(); return resources; } @@ -302,7 +298,9 @@ export async function deleteExpiredInAppMessages(userId: string): Promise({ query, parameters }).fetchAll(); + const { resources } = await container.items + .query<{ id: string }>({ query, parameters }) + .fetchAll(); let deleted = 0; for (const { id } of resources) { diff --git a/services/platform-service/src/modules/surveys/repository.ts b/services/platform-service/src/modules/surveys/repository.ts index 45ffac76..3b4f0ae2 100644 --- a/services/platform-service/src/modules/surveys/repository.ts +++ b/services/platform-service/src/modules/surveys/repository.ts @@ -56,8 +56,9 @@ export async function listSurveys( .fetchAll(); const total = countResult[0] ?? 0; - if (options?.offset) query += ` OFFSET ${options.offset}`; - if (options?.limit) query += ` LIMIT ${options.limit}`; + if (options?.limit) { + query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`; + } const { resources } = await container.items.query({ query, parameters }).fetchAll(); return { surveys: resources, total }; @@ -164,7 +165,7 @@ export async function listResponsesForSurvey( if (options?.isComplete !== undefined) { query += ' AND c.isComplete = @isComplete'; - parameters.push({ name: '@isComplete', value: options.isComplete.toString() }); + parameters.push({ name: '@isComplete', value: options.isComplete as unknown as string }); } query += ' ORDER BY c.createdAt DESC'; @@ -175,10 +176,13 @@ export async function listResponsesForSurvey( .fetchAll(); const total = countResult[0] ?? 0; - if (options?.offset) query += ` OFFSET ${options.offset}`; - if (options?.limit) query += ` LIMIT ${options.limit}`; + if (options?.limit) { + query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`; + } - const { resources } = await container.items.query({ query, parameters }).fetchAll(); + const { resources } = await container.items + .query({ query, parameters }) + .fetchAll(); return { responses: resources, total }; } @@ -188,8 +192,10 @@ export async function listRespondents(surveyId: string): Promise { 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); + const { resources } = await container.items + .query<{ userId: string }>({ query, parameters }) + .fetchAll(); + return resources.map(r => r.userId); } // ============================================================================= @@ -300,7 +306,10 @@ export interface QuestionAnalytics { sampleResponses?: string[]; } -export async function getSurveyAnalytics(surveyId: string): Promise<{ +export async function getSurveyAnalytics( + surveyId: string, + productId: string +): Promise<{ totalStarts: number; totalCompletions: number; completionRate: number; @@ -310,21 +319,21 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ const { responses } = await listResponsesForSurvey(surveyId); const totalStarts = responses.length; - const completedResponses = responses.filter((r) => r.isComplete); + const completedResponses = responses.filter(r => r.isComplete); const totalCompletions = completedResponses.length; // Calculate average time const times = completedResponses - .map((r) => { + .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); + .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 + const survey = await getSurvey(surveyId, productId); if (!survey) { return { totalStarts, @@ -336,7 +345,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ } // Aggregate per-question analytics - const questionAnalytics: QuestionAnalytics[] = survey.questions.map((q) => { + const questionAnalytics: QuestionAnalytics[] = survey.questions.map(q => { const qa: QuestionAnalytics = { questionId: q.id, questionType: q.type, @@ -344,7 +353,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ }; const answers = responses - .map((r) => r.answers[q.id]) + .map(r => r.answers[q.id]) .filter((a): a is QuestionAnswer => a !== undefined); qa.totalResponses = answers.length; @@ -352,7 +361,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ // Type-specific aggregation if (q.type === 'single_choice' || q.type === 'dropdown') { qa.optionCounts = {}; - answers.forEach((a) => { + answers.forEach(a => { if (a.type === 'single_choice') { qa.optionCounts![a.optionId] = (qa.optionCounts![a.optionId] || 0) + 1; } @@ -361,9 +370,9 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ if (q.type === 'multiple_choice') { qa.optionCounts = {}; - answers.forEach((a) => { + answers.forEach(a => { if (a.type === 'multiple_choice') { - a.optionIds.forEach((id) => { + a.optionIds.forEach(id => { qa.optionCounts![id] = (qa.optionCounts![id] || 0) + 1; }); } @@ -375,7 +384,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ .filter((a): a is { type: 'rating' | 'nps'; value: number } => ['rating', 'nps'].includes(a.type) ) - .map((a) => a.value); + .map(a => a.value); if (values.length > 0) { qa.average = values.reduce((a, b) => a + b, 0) / values.length; @@ -384,7 +393,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ // Build distribution qa.distribution = {}; - values.forEach((v) => { + values.forEach(v => { qa.distribution![v] = (qa.distribution![v] || 0) + 1; }); } @@ -393,13 +402,13 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{ 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) + .map(a => a.value) .slice(0, 10); // Sample first 10 } if (q.type === 'ranking') { qa.optionCounts = {}; - answers.forEach((a) => { + answers.forEach(a => { if (a.type === 'ranking') { // Count first-choice rankings const firstChoice = a.rankedOptionIds[0]; @@ -430,24 +439,23 @@ 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))) - ); + const questionIds = Array.from(new Set(responses.flatMap(r => Object.keys(r.answers)))); // Build headers - const headers = ['responseId', 'userId', 'startedAt', 'completedAt', 'isComplete', ...questionIds]; + 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 rows = responses.map(r => { + const base = [r.id, r.userId, r.startedAt, r.completedAt ?? '', r.isComplete ? 'yes' : 'no']; - const answers = questionIds.map((qid) => { + const answers = questionIds.map(qid => { const ans = r.answers[qid]; if (!ans) return ''; @@ -475,5 +483,5 @@ export function exportResponsesToCSV(responses: SurveyResponse[]): string { // Combine const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s}"` : s); - return [headers.join(','), ...rows.map((r) => r.map(escape).join(','))].join('\n'); + 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 index 90f3db12..52abd4b3 100644 --- a/services/platform-service/src/modules/surveys/routes.ts +++ b/services/platform-service/src/modules/surveys/routes.ts @@ -47,7 +47,7 @@ function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): str async function adminRoutes(app: FastifyInstance): Promise { // List all surveys - app.get('/', async (req) => { + app.get('/', async req => { const adminId = requireAdmin(req); const productId = getRequestProductId(req); @@ -57,7 +57,7 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // Add computed completion rate - const surveysWithRate = surveys.map((s) => ({ + const surveysWithRate = surveys.map(s => ({ ...s, computedCompletionRate: computeCompletionRate(s.metrics), })); @@ -67,7 +67,7 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // Get single survey with questions - app.get<{ Params: { id: string } }>('/:id', async (req) => { + app.get<{ Params: { id: string } }>('/:id', async req => { requireAdmin(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -112,7 +112,7 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // Update survey - app.put<{ Params: { id: string } }>('/:id', async (req) => { + app.put<{ Params: { id: string } }>('/:id', async req => { const adminId = requireAdmin(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -148,7 +148,7 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // Duplicate survey - app.post<{ Params: { id: string } }>('/:id/duplicate', async (req) => { + app.post<{ Params: { id: string } }>('/:id/duplicate', async req => { const adminId = requireAdmin(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -181,7 +181,7 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // Pause survey - app.post<{ Params: { id: string } }>('/:id/pause', async (req) => { + app.post<{ Params: { id: string } }>('/:id/pause', async req => { const adminId = requireAdmin(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -200,31 +200,41 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // List responses - app.get<{ Params: { id: string } }>('/:id/responses', async (req) => { + 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 { + const { + isComplete, + limit: limitStr, + offset: offsetStr, + } = req.query as { isComplete?: string; limit?: string; offset?: string; }; + const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50; + const parsedOffset = offsetStr ? parseInt(offsetStr, 10) : 0; + const safeLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50; + const safeOffset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; + 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), + limit: safeLimit, + offset: safeOffset, }); return { responses, total }; }); // List respondents (just user IDs) - app.get<{ Params: { id: string } }>('/:id/respondents', async (req) => { + app.get<{ Params: { id: string } }>('/:id/respondents', async req => { requireAdmin(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -237,7 +247,7 @@ async function adminRoutes(app: FastifyInstance): Promise { }); // Get analytics - app.get<{ Params: { id: string } }>('/:id/analytics', async (req) => { + app.get<{ Params: { id: string } }>('/:id/analytics', async req => { requireAdmin(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -245,7 +255,7 @@ async function adminRoutes(app: FastifyInstance): Promise { const survey = await repo.getSurvey(id, productId); if (!survey) throw new NotFoundError('Survey not found'); - const analytics = await repo.getSurveyAnalytics(id); + const analytics = await repo.getSurveyAnalytics(id, productId); return { surveyId: id, @@ -287,7 +297,7 @@ async function adminRoutes(app: FastifyInstance): Promise { async function publicRoutes(app: FastifyInstance): Promise { // Get active survey for user (rate limited in server.ts) - app.get('/active', async (req) => { + app.get('/active', async req => { const userId = requireAuth(req); const productId = getRequestProductId(req); @@ -312,7 +322,7 @@ async function publicRoutes(app: FastifyInstance): Promise { 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[], + userSegments: segments as string[] as import('../broadcasts/types.js').UserSegment[], }; // Find all active surveys @@ -356,7 +366,7 @@ async function publicRoutes(app: FastifyInstance): Promise { }); // Start survey session - app.post<{ Params: { id: string } }>('/:id/start', async (req) => { + app.post<{ Params: { id: string } }>('/:id/start', async req => { const userId = requireAuth(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -424,7 +434,7 @@ async function publicRoutes(app: FastifyInstance): Promise { }); // Submit answer - app.post<{ Params: { id: string } }>('/:id/response', async (req) => { + app.post<{ Params: { id: string } }>('/:id/response', async req => { const userId = requireAuth(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -435,7 +445,7 @@ async function publicRoutes(app: FastifyInstance): Promise { if (!survey) throw new NotFoundError('Survey not found'); // Find the question - const question = survey.questions.find((q) => q.id === questionId); + const question = survey.questions.find(q => q.id === questionId); if (!question) throw new NotFoundError('Question not found'); // Validate answer type matches question type @@ -454,7 +464,7 @@ async function publicRoutes(app: FastifyInstance): Promise { const updatedAnswers = { ...response.answers, [questionId]: answer }; // Find next question index (skipping conditional questions) - let nextIndex = response.currentQuestionIndex + 1; + const nextIndex = response.currentQuestionIndex + 1; response = await repo.updateResponse(id, userId, { answers: updatedAnswers, @@ -471,7 +481,7 @@ async function publicRoutes(app: FastifyInstance): Promise { }); // Complete survey - app.post<{ Params: { id: string } }>('/:id/complete', async (req) => { + app.post<{ Params: { id: string } }>('/:id/complete', async req => { const userId = requireAuth(req); const productId = getRequestProductId(req); const { id } = req.params; @@ -485,12 +495,10 @@ async function publicRoutes(app: FastifyInstance): Promise { const now = new Date().toISOString(); // Check for required questions - const unansweredRequired = survey.questions.filter( - (q) => q.required && !response.answers[q.id] - ); + 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(', ')}` + `Required questions unanswered: ${unansweredRequired.map(q => q.id).join(', ')}` ); } @@ -554,7 +562,7 @@ async function publicRoutes(app: FastifyInstance): Promise { }); // Dismiss survey - app.post<{ Params: { id: string } }>('/:id/dismiss', async (req) => { + app.post<{ Params: { id: string } }>('/:id/dismiss', async req => { const userId = requireAuth(req); const productId = getRequestProductId(req); const { id } = req.params;