Files
static-pages/apps/survey.immich.app/backend/src/routes/results.ts
T
f4b32da07a feat: add survey app (#422)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Zack Pollard <zackpollard@Zacks-MBP.local>
2026-08-15 11:18:16 -04:00

129 lines
5.6 KiB
TypeScript

import { createRespondentService } from '../services/factory';
import { ServiceError } from '../services/errors';
import { MAX_PAGINATION_LIMIT } from '../constants';
import { requireRole, type AuthenticatedRequest } from '../middleware/auth';
import { getContext } from '../config';
import type { AppRouter } from '../types';
export function registerResultRoutes(router: AppRouter) {
router.get('/api/surveys/:id/results', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const results = await service.getResults(request.params.id);
return Response.json(results);
});
router.get('/api/surveys/:id/results/export', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const format = url.searchParams.get('format');
if (format !== 'csv' && format !== 'json') {
throw new ServiceError('format must be csv or json', 400);
}
const result = await service.exportResponses(request.params.id, format);
return new Response(result.data, {
headers: {
'Content-Type': result.contentType,
'Content-Disposition': `attachment; filename="${result.filename}"`,
},
});
});
router.get('/api/surveys/:id/results/live', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const results = await service.getLiveResults(request.params.id);
const etag = `"${results.respondentCounts.completed}-${results.respondentCounts.total}-${results.liveCounts.activeRespondents}-${results.liveCounts.activeViewers}"`;
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === etag) {
return new Response(null, { status: 304 });
}
const response = Response.json(results);
response.headers.set('ETag', etag);
response.headers.set('Cache-Control', 'private, no-cache');
return response;
});
router.get('/api/surveys/:id/results/timeline', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const raw = url.searchParams.get('granularity');
const granularity: 'minute' | 'hour' | 'day' = raw === 'minute' ? 'minute' : raw === 'hour' ? 'hour' : 'day';
const data = await service.getTimeline(request.params.id, granularity);
return Response.json(data);
});
router.get('/api/surveys/:id/results/completion-times', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getCompletionTimes(request.params.id);
return Response.json(data);
});
router.get('/api/surveys/:id/results/question-timings', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getQuestionTimings(request.params.id);
return Response.json(data);
});
router.get('/api/surveys/:id/results/dropoff', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getDropoff(request.params.id);
return Response.json(data);
});
router.get('/api/surveys/:id/results/respondents', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Math.min(Number(url.searchParams.get('limit')) || 20, MAX_PAGINATION_LIMIT);
const data = await service.listRespondents(request.params.id, offset, limit);
return Response.json(data);
});
router.get('/api/surveys/:id/results/respondents/:rid', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getRespondentDetail(request.params.id, request.params.rid);
return Response.json(data);
});
router.delete('/api/surveys/:id/results/respondents/:rid', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
await service.deleteRespondent(request.params.id, request.params.rid);
return new Response(null, { status: 204 });
});
router.get('/api/surveys/:id/results/search', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const query = url.searchParams.get('q') ?? '';
const questionId = url.searchParams.get('questionId') ?? undefined;
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, MAX_PAGINATION_LIMIT);
const data = await service.searchAnswers(request.params.id, query, questionId, { offset, limit });
return Response.json(data);
});
}