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:
parent
652a8e5d15
commit
4071429871
@ -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) {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user