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,
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<Broadcast>({ query, parameters })
.fetchAll();
const { resources } = await container.items.query<Broadcast>({ 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<BroadcastDelivery>({ 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<InAppMessage>({ query, parameters })
.fetchAll();
const { resources } = await container.items.query<InAppMessage>({ query, parameters }).fetchAll();
return resources;
}
@ -302,7 +298,9 @@ export async function deleteExpiredInAppMessages(userId: string): Promise<number
{ 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;
for (const { id } of resources) {

View File

@ -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<Survey>({ 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<SurveyResponse>({ query, parameters }).fetchAll();
const { resources } = await container.items
.query<SurveyResponse>({ query, parameters })
.fetchAll();
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 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');
}

View File

@ -47,7 +47,7 @@ function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): str
async function adminRoutes(app: FastifyInstance): Promise<void> {
// 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<void> {
});
// 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<void> {
});
// 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<void> {
});
// 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<void> {
});
// 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<void> {
});
// 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<void> {
});
// 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<void> {
});
// 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<void> {
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<void> {
async function publicRoutes(app: FastifyInstance): Promise<void> {
// 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<void> {
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<void> {
});
// 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<void> {
});
// 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<void> {
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<void> {
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<void> {
});
// 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<void> {
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<void> {
});
// 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;