Files
static-pages/apps/survey.immich.app/backend/src/services/respondent.service.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

606 lines
20 KiB
TypeScript

import { RespondentRepository, AnswerRepository, type AnswerRow } from '../repositories/respondent.repository';
import {
SurveyRepository,
QuestionRepository,
type QuestionRow,
type SurveyRow,
} from '../repositories/survey.repository';
import { ServiceError } from './errors';
import { BATCH_ANSWER_LIMIT, COMPLETION_TIME_BUCKETS, clampAnswerMs, percentile } from '../constants';
import { validateAnswer, type QuestionSpec } from '../../../shared/answer-validation';
export interface ResumeResult {
answers: Record<string, { value: string; otherText?: string }>;
nextQuestionIndex: number;
isComplete: boolean;
respondentId: string;
isNewRespondent: boolean;
}
export interface BatchAnswerInput {
questionId: string;
value: string;
otherText?: string;
answerMs?: number;
}
export interface AggregatedResult {
questionId: string;
answers: Array<{ value: string; otherText: string | null; count: number }>;
}
export class RespondentService {
constructor(
private respondents: RespondentRepository,
private answers: AnswerRepository,
private surveys: SurveyRepository,
private questions: QuestionRepository,
) {}
private checkSurveyClosed(survey: SurveyRow): void {
if (survey.closes_at && new Date(survey.closes_at) < new Date()) {
throw new ServiceError('This survey has closed', 403);
}
}
private async checkResponseLimit(survey: SurveyRow): Promise<void> {
if (survey.max_responses) {
const counts = await this.respondents.countBySurveyId(survey.id);
if (counts.completed >= survey.max_responses) {
throw new ServiceError('This survey has reached its maximum number of responses', 403);
}
}
}
async resume(
slug: string,
respondentId: string | undefined,
ipAddress: string,
knownSurvey?: SurveyRow,
): Promise<ResumeResult> {
const survey = knownSurvey ?? (await this.surveys.getBySlug(slug));
if (!survey || survey.status !== 'published') {
throw new ServiceError('Survey not found', 404);
}
this.checkSurveyClosed(survey);
await this.checkResponseLimit(survey);
if (!respondentId) {
return this.createNewRespondent(survey.id, ipAddress);
}
const respondent = await this.respondents.getById(respondentId);
if (!respondent || respondent.survey_id !== survey.id) {
return this.createNewRespondent(survey.id, ipAddress);
}
if (respondent.is_complete) {
return {
answers: {},
nextQuestionIndex: 0,
isComplete: true,
respondentId: respondent.id,
isNewRespondent: false,
};
}
const answerRows = await this.answers.getByRespondentId(respondentId);
const answersMap: Record<string, { value: string; otherText?: string }> = {};
for (const row of answerRows) {
answersMap[row.question_id] = {
value: row.answer,
...(row.other_text ? { otherText: row.other_text } : {}),
};
}
const questions = await this.questions.getBySurveyId(survey.id);
const nextQuestionIndex = this.findResumeIndex(questions, answersMap);
return {
answers: answersMap,
nextQuestionIndex,
isComplete: false,
respondentId: respondent.id,
isNewRespondent: false,
};
}
async submitBatch(
slug: string,
respondentId: string,
inputs: BatchAnswerInput[],
knownSurvey?: SurveyRow,
): Promise<void> {
if (!inputs || !Array.isArray(inputs) || inputs.length === 0 || inputs.length > BATCH_ANSWER_LIMIT) {
throw new ServiceError(`Invalid answers payload: must be 1-${BATCH_ANSWER_LIMIT} answers`, 400);
}
const respondent = await this.respondents.getById(respondentId);
if (!respondent) {
throw new ServiceError('Respondent not found', 404);
}
const survey = knownSurvey ?? (await this.surveys.getBySlug(slug));
if (!survey || respondent.survey_id !== survey.id) {
throw new ServiceError('Respondent does not belong to this survey', 403);
}
// Completion is terminal: without this guard the upsert would let a
// completed respondent silently rewrite their answers and answer_ms.
if (respondent.is_complete) {
throw new ServiceError('Survey already completed', 409);
}
this.checkSurveyClosed(survey);
const surveyQuestions = await this.questions.getBySurveyId(survey.id);
const questionMap = new Map(surveyQuestions.map((sq) => [sq.id, sq]));
for (const input of inputs) {
const sq = questionMap.get(input.questionId);
if (!sq) throw new ServiceError(`Invalid question ID: ${input.questionId}`, 400);
const spec: QuestionSpec = {
type: sq.type,
required: sq.required === 1,
options: sq.options ? JSON.parse(sq.options) : undefined,
hasOther: sq.has_other === 1,
maxLength: sq.max_length ?? undefined,
config: sq.config ? JSON.parse(sq.config) : undefined,
};
const error = validateAnswer(spec, input.value, input.otherText);
if (error) throw new ServiceError(error, 400);
}
const now = new Date().toISOString();
const answerRows: AnswerRow[] = inputs.map((a) => ({
respondent_id: respondentId,
question_id: a.questionId,
answer: a.value,
other_text: a.otherText ?? null,
answered_at: now,
answer_ms: clampAnswerMs(a.answerMs),
}));
await this.answers.upsertBatch(answerRows);
}
/**
* Returns true only on the 0→1 transition; a duplicate `complete` returns
* false so callers don't double-count it.
*/
async complete(slug: string, respondentId: string, knownSurvey?: SurveyRow): Promise<boolean> {
const respondent = await this.respondents.getById(respondentId);
if (!respondent) {
throw new ServiceError('Respondent not found', 404);
}
const survey = knownSurvey ?? (await this.surveys.getBySlug(slug));
if (!survey || respondent.survey_id !== survey.id) {
throw new ServiceError('Respondent does not belong to this survey', 403);
}
return this.respondents.markComplete(respondentId);
}
async deleteRespondent(surveyId: string, respondentId: string): Promise<void> {
const respondent = await this.respondents.getById(respondentId);
if (!respondent || respondent.survey_id !== surveyId) {
throw new ServiceError('Respondent not found', 404);
}
await this.respondents.deleteWithAnswers(respondentId);
}
async getResults(surveyId: string): Promise<{
respondentCounts: { total: number; completed: number };
results: AggregatedResult[];
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) {
throw new ServiceError('Survey not found', 404);
}
const [respondentCounts, rawResults] = await Promise.all([
this.respondents.countBySurveyId(surveyId),
this.answers.getAggregatedResults(surveyId),
]);
const grouped = new Map<string, AggregatedResult>();
for (const row of rawResults) {
if (!grouped.has(row.question_id)) {
grouped.set(row.question_id, { questionId: row.question_id, answers: [] });
}
grouped.get(row.question_id)?.answers.push({
value: row.answer,
otherText: row.other_text,
count: row.count,
});
}
return {
respondentCounts,
results: [...grouped.values()],
};
}
async exportResponses(
surveyId: string,
format: 'csv' | 'json',
): Promise<{ data: ReadableStream<Uint8Array>; contentType: string; filename: string }> {
const survey = await this.surveys.getById(surveyId);
if (!survey) {
throw new ServiceError('Survey not found', 404);
}
const questions = await this.questions.getBySurveyId(surveyId);
const responses = await this.answers.getAllResponsesForSurvey(surveyId);
// Rows must be collapsed per respondent before any record can be emitted,
// so the grouping is buffered; only the output below is streamed.
const respondentMap = new Map<
string,
{ completedAt: string | null; answers: Map<string, { value: string; otherText: string | null }> }
>();
for (const row of responses) {
let entry = respondentMap.get(row.respondent_id);
if (!entry) {
entry = { completedAt: row.completed_at, answers: new Map() };
respondentMap.set(row.respondent_id, entry);
}
entry.answers.set(row.question_id, { value: row.answer, otherText: row.other_text });
}
const encoder = new TextEncoder();
const filenameBase = `${survey.slug ?? survey.id}-responses`;
if (format === 'json') {
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
controller.enqueue(encoder.encode('[\n'));
let first = true;
for (const [respondentId, entry] of respondentMap) {
const record = {
respondentId,
completedAt: entry.completedAt,
answers: Object.fromEntries(entry.answers),
};
controller.enqueue(encoder.encode((first ? '' : ',\n') + JSON.stringify(record, null, 2)));
first = false;
}
controller.enqueue(encoder.encode('\n]\n'));
controller.close();
},
});
return { data: stream, contentType: 'application/json', filename: `${filenameBase}.json` };
}
const questionColumns = questions.map((q) => ({ id: q.id, text: q.text, hasOther: q.has_other === 1 }));
const headers = ['respondent_id', 'completed_at'];
for (const col of questionColumns) {
headers.push(this.csvSafe(col.text));
if (col.hasOther) headers.push(this.csvSafe(col.text) + '_other');
}
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
controller.enqueue(encoder.encode(headers.map((h) => `"${h}"`).join(',') + '\n'));
for (const [respondentId, entry] of respondentMap) {
const row: string[] = [`"${respondentId}"`, `"${entry.completedAt ?? ''}"`];
for (const col of questionColumns) {
const answer = entry.answers.get(col.id);
row.push(`"${this.csvSafe(answer?.value ?? '')}"`);
if (col.hasOther) row.push(`"${this.csvSafe(answer?.otherText ?? '')}"`);
}
controller.enqueue(encoder.encode(row.join(',') + '\n'));
}
controller.close();
},
});
return { data: stream, contentType: 'text/csv', filename: `${filenameBase}.csv` };
}
async getTimeline(
surveyId: string,
granularity: 'minute' | 'hour' | 'day',
): Promise<Array<{ period: string; started: number; completed: number }>> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
return this.respondents.getTimelineData(surveyId, granularity);
}
async getQuestionTimings(surveyId: string): Promise<
Array<{
questionId: string;
questionText: string;
sampleSize: number;
meanMs: number | null;
medianMs: number | null;
p5Ms: number | null;
p25Ms: number | null;
p75Ms: number | null;
p95Ms: number | null;
minMs: number | null;
maxMs: number | null;
}>
> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const rows = await this.respondents.getAnswerDurationsByQuestion(surveyId);
if (rows.length === 0) return [];
const byQ = new Map<string, { questionId: string; questionText: string; sort: number; durations: number[] }>();
for (const r of rows) {
let entry = byQ.get(r.question_id);
if (!entry) {
entry = { questionId: r.question_id, questionText: r.question_text, sort: r.question_sort, durations: [] };
byQ.set(r.question_id, entry);
}
entry.durations.push(r.answer_ms);
}
return [...byQ.values()]
.sort((a, b) => a.sort - b.sort)
.map((q) => {
const sorted = [...q.durations].sort((a, b) => a - b);
const n = sorted.length;
const sum = sorted.reduce((acc, v) => acc + v, 0);
return {
questionId: q.questionId,
questionText: q.questionText,
sampleSize: n,
meanMs: n > 0 ? Math.round(sum / n) : null,
medianMs: percentile(sorted, 50),
p5Ms: percentile(sorted, 5),
p25Ms: percentile(sorted, 25),
p75Ms: percentile(sorted, 75),
p95Ms: percentile(sorted, 95),
minMs: n > 0 ? sorted[0] : null,
maxMs: n > 0 ? sorted[n - 1] : null,
};
});
}
async getCompletionTimes(surveyId: string): Promise<{
count: number;
median: number | null;
mean: number | null;
p25: number | null;
p75: number | null;
min: number | null;
max: number | null;
buckets: Array<{ label: string; minSeconds: number; maxSeconds: number | null; count: number }>;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const durations = await this.respondents.getCompletionDurationsSeconds(surveyId);
const count = durations.length;
const buckets = COMPLETION_TIME_BUCKETS.map((b) => ({ ...b, count: 0 }));
for (const d of durations) {
for (const b of buckets) {
if (d >= b.minSeconds && (b.maxSeconds === null || d < b.maxSeconds)) {
b.count += 1;
break;
}
}
}
if (count === 0) {
return { count: 0, median: null, mean: null, p25: null, p75: null, min: null, max: null, buckets };
}
const sorted = [...durations].sort((a, b) => a - b);
const sum = sorted.reduce((acc, v) => acc + v, 0);
return {
count,
mean: Math.round(sum / count),
median: percentile(sorted, 50),
p25: percentile(sorted, 25),
p75: percentile(sorted, 75),
min: sorted[0],
max: sorted[sorted.length - 1],
buckets,
};
}
async getDropoff(surveyId: string): Promise<
Array<{
questionId: string;
questionText: string;
respondentsReached: number;
respondentsAnswered: number;
dropoffRate: number;
}>
> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const counts = await this.respondents.countBySurveyId(surveyId);
const dropoffData = await this.answers.getDropoffData(surveyId);
const totalRespondents = counts.total;
// Drop-off is measured against the PREVIOUS question's reached cohort, and
// the first question's baseline is the total respondents — so people who
// started but answered nothing register as a drop at question one.
return dropoffData.map((d, i) => {
const reached = d.reached_count;
const previousReached = i === 0 ? totalRespondents : dropoffData[i - 1].reached_count;
const dropoffRate = previousReached > 0 ? Math.round(((previousReached - reached) / previousReached) * 100) : 0;
return {
questionId: d.question_id,
questionText: d.question_text,
respondentsReached: reached,
respondentsAnswered: d.answer_count,
dropoffRate: Math.max(0, dropoffRate),
};
});
}
async listRespondents(
surveyId: string,
offset: number,
limit: number,
): Promise<{
respondents: Array<{ id: string; createdAt: string; completedAt: string | null; answerCount: number }>;
total: number;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const data = await this.respondents.listBySurveyId(surveyId, offset, limit);
return {
respondents: data.respondents.map((r) => ({
id: r.id,
createdAt: r.created_at,
completedAt: r.completed_at,
answerCount: r.answer_count,
})),
total: data.total,
};
}
async getRespondentDetail(
surveyId: string,
respondentId: string,
): Promise<{
id: string;
createdAt: string;
completedAt: string | null;
answers: Array<{
questionId: string;
questionText: string;
questionType: string;
value: string;
otherText: string | null;
}>;
}> {
const respondent = await this.respondents.getById(respondentId);
if (!respondent || respondent.survey_id !== surveyId) {
throw new ServiceError('Respondent not found', 404);
}
const answers = await this.answers.getAnswersForRespondent(respondentId);
return {
id: respondent.id,
createdAt: respondent.created_at,
completedAt: respondent.completed_at,
answers: answers.map((a) => ({
questionId: a.question_id,
questionText: a.question_text,
questionType: a.question_type,
value: a.answer,
otherText: a.other_text,
})),
};
}
async searchAnswers(
surveyId: string,
query: string,
questionId?: string,
pagination?: { offset: number; limit: number },
): Promise<{
results: Array<{ respondentId: string; questionId: string; questionText: string; answer: string }>;
total: number;
offset: number;
limit: number;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!query || query.trim().length < 2) {
throw new ServiceError('Search query must be at least 2 characters', 400);
}
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 50;
const { results: rawResults, total } = await this.answers.searchTextAnswers(
surveyId,
query.trim(),
questionId,
offset,
limit,
);
return {
results: rawResults.map((r) => ({
respondentId: r.respondent_id,
questionId: r.question_id,
questionText: r.question_text,
answer: r.answer,
})),
total,
offset,
limit,
};
}
async getLiveResults(
surveyId: string,
presenceCounts?: { activeViewers: number; activeRespondents: number },
): Promise<{
respondentCounts: { total: number; completed: number };
results: AggregatedResult[];
liveCounts: { activeViewers: number; activeRespondents: number };
}> {
const baseResults = await this.getResults(surveyId);
const liveCounts = presenceCounts ?? {
activeViewers: 0,
activeRespondents: await this.respondents.countActiveBySurveyId(surveyId),
};
return { ...baseResults, liveCounts };
}
private csvSafe(value: string): string {
// CSV injection (CWE-1236): spreadsheets evaluate cells starting with
// =, +, -, @, tab or CR as formulas, so an answer like `=HYPERLINK(...)`
// exfiltrates adjacent rows when an admin opens the export. The leading
// apostrophe defangs it (OWASP) and is stripped from the rendered cell.
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
return safe.replace(/"/g, '""');
}
private async createNewRespondent(surveyId: string, ipAddress: string): Promise<ResumeResult> {
const id = crypto.randomUUID();
await this.respondents.create({
id,
survey_id: surveyId,
ip_address: ipAddress,
is_complete: 0,
created_at: new Date().toISOString(),
completed_at: null,
});
return {
answers: {},
nextQuestionIndex: 0,
isComplete: false,
respondentId: id,
isNewRespondent: true,
};
}
/**
* Resume at the LAST answered question's index rather than the first
* unanswered one: optional questions a respondent deliberately skipped
* would otherwise send them backwards. Returns 0 when nothing is answered.
*/
private findResumeIndex(
questions: QuestionRow[],
answers: Record<string, { value: string; otherText?: string }>,
): number {
let lastAnsweredIndex = -1;
for (let i = 0; i < questions.length; i++) {
if (answers[questions[i].id]) {
lastAnsweredIndex = i;
}
}
// Resume ON that question, not after it — the answer may be half-finished
// (e.g. mid-typing when the tab crashed).
return Math.max(0, lastAnsweredIndex);
}
}