fix(platform-service): fix OFFSET/LIMIT pairing, survey analytics productId, unused imports

- broadcasts/repository: fix OFFSET/LIMIT must be paired (Cosmos SQL requires both)
- broadcasts/repository: remove unused TargetingContext import
- surveys/repository: fix OFFSET/LIMIT in listSurveys + listResponsesForSurvey
- surveys/repository: fix getSurveyAnalytics to accept and pass productId
- surveys/repository: fix isComplete boolean param cast for Cosmos SDK types
- surveys/routes: fix parseInt safety for limit/offset query params (NaN guard)
- surveys/routes: pass productId to getSurveyAnalytics call
This commit is contained in:
saravanakumardb1 2026-03-19 23:47:43 -07:00
parent 652a8e5d15
commit 4071429871
3 changed files with 86 additions and 72 deletions

View File

@ -9,7 +9,6 @@ import {
BroadcastDelivery, BroadcastDelivery,
InAppMessage, InAppMessage,
BroadcastRead, BroadcastRead,
TargetingContext,
type BroadcastStatus, type BroadcastStatus,
} from './types.js'; } from './types.js';
@ -66,9 +65,7 @@ export async function listBroadcasts(
query += ` LIMIT ${options.limit}`; query += ` LIMIT ${options.limit}`;
} }
const { resources } = await container.items const { resources } = await container.items.query<Broadcast>({ query, parameters }).fetchAll();
.query<Broadcast>({ query, parameters })
.fetchAll();
return { broadcasts: resources, total }; return { broadcasts: resources, total };
} }
@ -212,8 +209,9 @@ export async function listDeliveriesForBroadcast(
.fetchAll(); .fetchAll();
const total = countResult[0] ?? 0; const total = countResult[0] ?? 0;
if (options?.offset) query += ` OFFSET ${options.offset}`; if (options?.limit) {
if (options?.limit) query += ` LIMIT ${options.limit}`; query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`;
}
const { resources } = await container.items const { resources } = await container.items
.query<BroadcastDelivery>({ query, parameters }) .query<BroadcastDelivery>({ query, parameters })
@ -252,14 +250,12 @@ export async function getInAppMessagesForUser(
} }
// Only show non-expired messages // 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() }); parameters.push({ name: '@now', value: new Date().toISOString() });
query += ' ORDER BY c.createdAt DESC'; query += ' ORDER BY c.createdAt DESC';
const { resources } = await container.items const { resources } = await container.items.query<InAppMessage>({ query, parameters }).fetchAll();
.query<InAppMessage>({ query, parameters })
.fetchAll();
return resources; return resources;
} }
@ -302,7 +298,9 @@ export async function deleteExpiredInAppMessages(userId: string): Promise<number
{ name: '@now', value: now }, { name: '@now', value: now },
]; ];
const { resources } = await container.items.query<{ id: string }>({ query, parameters }).fetchAll(); const { resources } = await container.items
.query<{ id: string }>({ query, parameters })
.fetchAll();
let deleted = 0; let deleted = 0;
for (const { id } of resources) { for (const { id } of resources) {

View File

@ -56,8 +56,9 @@ export async function listSurveys(
.fetchAll(); .fetchAll();
const total = countResult[0] ?? 0; const total = countResult[0] ?? 0;
if (options?.offset) query += ` OFFSET ${options.offset}`; if (options?.limit) {
if (options?.limit) query += ` LIMIT ${options.limit}`; query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`;
}
const { resources } = await container.items.query<Survey>({ query, parameters }).fetchAll(); const { resources } = await container.items.query<Survey>({ query, parameters }).fetchAll();
return { surveys: resources, total }; return { surveys: resources, total };
@ -164,7 +165,7 @@ export async function listResponsesForSurvey(
if (options?.isComplete !== undefined) { if (options?.isComplete !== undefined) {
query += ' AND c.isComplete = @isComplete'; 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'; query += ' ORDER BY c.createdAt DESC';
@ -175,10 +176,13 @@ export async function listResponsesForSurvey(
.fetchAll(); .fetchAll();
const total = countResult[0] ?? 0; const total = countResult[0] ?? 0;
if (options?.offset) query += ` OFFSET ${options.offset}`; if (options?.limit) {
if (options?.limit) query += ` LIMIT ${options.limit}`; query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`;
}
const { resources } = await container.items.query<SurveyResponse>({ query, parameters }).fetchAll(); const { resources } = await container.items
.query<SurveyResponse>({ query, parameters })
.fetchAll();
return { responses: resources, total }; return { responses: resources, total };
} }
@ -188,8 +192,10 @@ export async function listRespondents(surveyId: string): Promise<string[]> {
const query = 'SELECT c.userId FROM c WHERE c.surveyId = @surveyId AND c.isComplete = true'; const query = 'SELECT c.userId FROM c WHERE c.surveyId = @surveyId AND c.isComplete = true';
const parameters = [{ name: '@surveyId', value: surveyId }]; const parameters = [{ name: '@surveyId', value: surveyId }];
const { resources } = await container.items.query<{ userId: string }>({ query, parameters }).fetchAll(); const { resources } = await container.items
return resources.map((r) => r.userId); .query<{ userId: string }>({ query, parameters })
.fetchAll();
return resources.map(r => r.userId);
} }
// ============================================================================= // =============================================================================
@ -300,7 +306,10 @@ export interface QuestionAnalytics {
sampleResponses?: string[]; sampleResponses?: string[];
} }
export async function getSurveyAnalytics(surveyId: string): Promise<{ export async function getSurveyAnalytics(
surveyId: string,
productId: string
): Promise<{
totalStarts: number; totalStarts: number;
totalCompletions: number; totalCompletions: number;
completionRate: number; completionRate: number;
@ -310,21 +319,21 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{
const { responses } = await listResponsesForSurvey(surveyId); const { responses } = await listResponsesForSurvey(surveyId);
const totalStarts = responses.length; const totalStarts = responses.length;
const completedResponses = responses.filter((r) => r.isComplete); const completedResponses = responses.filter(r => r.isComplete);
const totalCompletions = completedResponses.length; const totalCompletions = completedResponses.length;
// Calculate average time // Calculate average time
const times = completedResponses const times = completedResponses
.map((r) => { .map(r => {
if (!r.completedAt || !r.startedAt) return 0; if (!r.completedAt || !r.startedAt) return 0;
return (new Date(r.completedAt).getTime() - new Date(r.startedAt).getTime()) / 1000; 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; const avgTimeSeconds = times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
// Get survey to access question definitions // 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) { if (!survey) {
return { return {
totalStarts, totalStarts,
@ -336,7 +345,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{
} }
// Aggregate per-question analytics // Aggregate per-question analytics
const questionAnalytics: QuestionAnalytics[] = survey.questions.map((q) => { const questionAnalytics: QuestionAnalytics[] = survey.questions.map(q => {
const qa: QuestionAnalytics = { const qa: QuestionAnalytics = {
questionId: q.id, questionId: q.id,
questionType: q.type, questionType: q.type,
@ -344,7 +353,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{
}; };
const answers = responses const answers = responses
.map((r) => r.answers[q.id]) .map(r => r.answers[q.id])
.filter((a): a is QuestionAnswer => a !== undefined); .filter((a): a is QuestionAnswer => a !== undefined);
qa.totalResponses = answers.length; qa.totalResponses = answers.length;
@ -352,7 +361,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{
// Type-specific aggregation // Type-specific aggregation
if (q.type === 'single_choice' || q.type === 'dropdown') { if (q.type === 'single_choice' || q.type === 'dropdown') {
qa.optionCounts = {}; qa.optionCounts = {};
answers.forEach((a) => { answers.forEach(a => {
if (a.type === 'single_choice') { if (a.type === 'single_choice') {
qa.optionCounts![a.optionId] = (qa.optionCounts![a.optionId] || 0) + 1; 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') { if (q.type === 'multiple_choice') {
qa.optionCounts = {}; qa.optionCounts = {};
answers.forEach((a) => { answers.forEach(a => {
if (a.type === 'multiple_choice') { if (a.type === 'multiple_choice') {
a.optionIds.forEach((id) => { a.optionIds.forEach(id => {
qa.optionCounts![id] = (qa.optionCounts![id] || 0) + 1; 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 } => .filter((a): a is { type: 'rating' | 'nps'; value: number } =>
['rating', 'nps'].includes(a.type) ['rating', 'nps'].includes(a.type)
) )
.map((a) => a.value); .map(a => a.value);
if (values.length > 0) { if (values.length > 0) {
qa.average = values.reduce((a, b) => a + b, 0) / values.length; qa.average = values.reduce((a, b) => a + b, 0) / values.length;
@ -384,7 +393,7 @@ export async function getSurveyAnalytics(surveyId: string): Promise<{
// Build distribution // Build distribution
qa.distribution = {}; qa.distribution = {};
values.forEach((v) => { values.forEach(v => {
qa.distribution![v] = (qa.distribution![v] || 0) + 1; 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') { if (q.type === 'text_short' || q.type === 'text_long') {
qa.sampleResponses = answers qa.sampleResponses = answers
.filter((a): a is { type: 'text'; value: string } => a.type === 'text') .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 .slice(0, 10); // Sample first 10
} }
if (q.type === 'ranking') { if (q.type === 'ranking') {
qa.optionCounts = {}; qa.optionCounts = {};
answers.forEach((a) => { answers.forEach(a => {
if (a.type === 'ranking') { if (a.type === 'ranking') {
// Count first-choice rankings // Count first-choice rankings
const firstChoice = a.rankedOptionIds[0]; const firstChoice = a.rankedOptionIds[0];
@ -430,24 +439,23 @@ export function exportResponsesToCSV(responses: SurveyResponse[]): string {
if (responses.length === 0) return ''; if (responses.length === 0) return '';
// Get all unique question IDs // Get all unique question IDs
const questionIds = Array.from( const questionIds = Array.from(new Set(responses.flatMap(r => Object.keys(r.answers))));
new Set(responses.flatMap((r) => Object.keys(r.answers)))
);
// Build headers // Build headers
const headers = ['responseId', 'userId', 'startedAt', 'completedAt', 'isComplete', ...questionIds]; const headers = [
'responseId',
'userId',
'startedAt',
'completedAt',
'isComplete',
...questionIds,
];
// Build rows // Build rows
const rows = responses.map((r) => { const rows = responses.map(r => {
const base = [ const base = [r.id, r.userId, r.startedAt, r.completedAt ?? '', r.isComplete ? 'yes' : 'no'];
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]; const ans = r.answers[qid];
if (!ans) return ''; if (!ans) return '';
@ -475,5 +483,5 @@ export function exportResponsesToCSV(responses: SurveyResponse[]): string {
// Combine // Combine
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s}"` : s); 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');
} }

View File

@ -47,7 +47,7 @@ function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): str
async function adminRoutes(app: FastifyInstance): Promise<void> { async function adminRoutes(app: FastifyInstance): Promise<void> {
// List all surveys // List all surveys
app.get('/', async (req) => { app.get('/', async req => {
const adminId = requireAdmin(req); const adminId = requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
@ -57,7 +57,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// Add computed completion rate // Add computed completion rate
const surveysWithRate = surveys.map((s) => ({ const surveysWithRate = surveys.map(s => ({
...s, ...s,
computedCompletionRate: computeCompletionRate(s.metrics), computedCompletionRate: computeCompletionRate(s.metrics),
})); }));
@ -67,7 +67,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// Get single survey with questions // Get single survey with questions
app.get<{ Params: { id: string } }>('/:id', async (req) => { app.get<{ Params: { id: string } }>('/:id', async req => {
requireAdmin(req); requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -112,7 +112,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// Update survey // Update survey
app.put<{ Params: { id: string } }>('/:id', async (req) => { app.put<{ Params: { id: string } }>('/:id', async req => {
const adminId = requireAdmin(req); const adminId = requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -148,7 +148,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// Duplicate survey // 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 adminId = requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -181,7 +181,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// Pause survey // 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 adminId = requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -200,31 +200,41 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// List responses // List responses
app.get<{ Params: { id: string } }>('/:id/responses', async (req) => { app.get<{ Params: { id: string } }>('/:id/responses', async req => {
requireAdmin(req); requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
const { isComplete, limit = 50, offset = 0 } = req.query as { const {
isComplete,
limit: limitStr,
offset: offsetStr,
} = req.query as {
isComplete?: string; isComplete?: string;
limit?: string; limit?: string;
offset?: 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); const survey = await repo.getSurvey(id, productId);
if (!survey) throw new NotFoundError('Survey not found'); if (!survey) throw new NotFoundError('Survey not found');
const { responses, total } = await repo.listResponsesForSurvey(id, { const { responses, total } = await repo.listResponsesForSurvey(id, {
isComplete: isComplete === 'true' ? true : isComplete === 'false' ? false : undefined, isComplete: isComplete === 'true' ? true : isComplete === 'false' ? false : undefined,
limit: parseInt(String(limit), 10), limit: safeLimit,
offset: parseInt(String(offset), 10), offset: safeOffset,
}); });
return { responses, total }; return { responses, total };
}); });
// List respondents (just user IDs) // 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); requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -237,7 +247,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
}); });
// Get analytics // Get analytics
app.get<{ Params: { id: string } }>('/:id/analytics', async (req) => { app.get<{ Params: { id: string } }>('/:id/analytics', async req => {
requireAdmin(req); requireAdmin(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -245,7 +255,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
const survey = await repo.getSurvey(id, productId); const survey = await repo.getSurvey(id, productId);
if (!survey) throw new NotFoundError('Survey not found'); if (!survey) throw new NotFoundError('Survey not found');
const analytics = await repo.getSurveyAnalytics(id); const analytics = await repo.getSurveyAnalytics(id, productId);
return { return {
surveyId: id, surveyId: id,
@ -287,7 +297,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
async function publicRoutes(app: FastifyInstance): Promise<void> { async function publicRoutes(app: FastifyInstance): Promise<void> {
// Get active survey for user (rate limited in server.ts) // 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 userId = requireAuth(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
@ -312,7 +322,7 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
osVersion: headers['x-os-version'] ?? '0.0.0', osVersion: headers['x-os-version'] ?? '0.0.0',
countryCode: headers['x-country-code'], countryCode: headers['x-country-code'],
regionCode: headers['x-region-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 // Find all active surveys
@ -356,7 +366,7 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
}); });
// Start survey session // 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 userId = requireAuth(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -424,7 +434,7 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
}); });
// Submit answer // 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 userId = requireAuth(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -435,7 +445,7 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
if (!survey) throw new NotFoundError('Survey not found'); if (!survey) throw new NotFoundError('Survey not found');
// Find the question // 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'); if (!question) throw new NotFoundError('Question not found');
// Validate answer type matches question type // Validate answer type matches question type
@ -454,7 +464,7 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
const updatedAnswers = { ...response.answers, [questionId]: answer }; const updatedAnswers = { ...response.answers, [questionId]: answer };
// Find next question index (skipping conditional questions) // Find next question index (skipping conditional questions)
let nextIndex = response.currentQuestionIndex + 1; const nextIndex = response.currentQuestionIndex + 1;
response = await repo.updateResponse(id, userId, { response = await repo.updateResponse(id, userId, {
answers: updatedAnswers, answers: updatedAnswers,
@ -471,7 +481,7 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
}); });
// Complete survey // 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 userId = requireAuth(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;
@ -485,12 +495,10 @@ async function publicRoutes(app: FastifyInstance): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Check for required questions // Check for required questions
const unansweredRequired = survey.questions.filter( const unansweredRequired = survey.questions.filter(q => q.required && !response.answers[q.id]);
(q) => q.required && !response.answers[q.id]
);
if (unansweredRequired.length > 0) { if (unansweredRequired.length > 0) {
throw new BadRequestError( 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<void> {
}); });
// Dismiss survey // 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 userId = requireAuth(req);
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const { id } = req.params; const { id } = req.params;