mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
fix: linting
This commit is contained in:
@@ -58,7 +58,7 @@ function parseArgs(): Config {
|
||||
const args = process.argv.slice(2);
|
||||
const get = (flag: string, def: string) => {
|
||||
const i = args.indexOf(flag);
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : def;
|
||||
return i !== -1 && args[i + 1] ? args[i + 1] : def;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -194,7 +194,7 @@ class Metrics {
|
||||
|
||||
const errorGroups = new Map<string, number>();
|
||||
for (const m of this.data) {
|
||||
if (!m.error) continue;
|
||||
if (!m.error) {continue;}
|
||||
// Normalize "retry in Xms" to make messages comparable
|
||||
const normalized = m.error.replace(/retry in \d+ms/, 'retry');
|
||||
const key = `${m.endpoint}: ${normalized}`;
|
||||
@@ -205,7 +205,7 @@ class Metrics {
|
||||
console.log(`\n${'-'.repeat(70)}`);
|
||||
console.log('ERROR BREAKDOWN');
|
||||
console.log('-'.repeat(70));
|
||||
const sorted = [...errorGroups.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const sorted = [...errorGroups].sort((a, b) => b[1] - a[1]);
|
||||
for (const [err, count] of sorted.slice(0, 10)) {
|
||||
console.log(` ${String(count).padStart(5)} ${err}`);
|
||||
}
|
||||
@@ -229,12 +229,12 @@ async function timedFetch(
|
||||
const res = await fetch(url, init);
|
||||
metrics.record({ endpoint, status: res.status, latencyMs: performance.now() - start });
|
||||
return res;
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
metrics.record({
|
||||
endpoint,
|
||||
status: 0,
|
||||
latencyMs: performance.now() - start,
|
||||
error: e instanceof Error ? e.message : 'Network error',
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -245,22 +245,18 @@ async function authenticate(baseUrl: string, password: string): Promise<string>
|
||||
const me = (await meRes.json()) as { authenticated: boolean; needsSetup?: boolean };
|
||||
|
||||
let res: Response;
|
||||
if (me.needsSetup) {
|
||||
res = await fetch(`${baseUrl}/api/auth/setup`, {
|
||||
res = me.needsSetup ? (await fetch(`${baseUrl}/api/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
} else {
|
||||
res = await fetch(`${baseUrl}/api/auth/password-login`, {
|
||||
})) : (await fetch(`${baseUrl}/api/auth/password-login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const cookie = res.headers.get('set-cookie')?.split(';')[0];
|
||||
if (!cookie) throw new Error('Authentication failed — check password');
|
||||
const cookie = res.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
if (!cookie) {throw new Error('Authentication failed — check password');}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
@@ -366,13 +362,13 @@ async function createSurvey(baseUrl: string, sessionCookie: string): Promise<{ s
|
||||
headers,
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
if (!slugRes.ok) throw new Error(`Set slug failed: ${await slugRes.text()}`);
|
||||
if (!slugRes.ok) {throw new Error(`Set slug failed: ${await slugRes.text()}`);}
|
||||
|
||||
const pubRes = await fetch(`${baseUrl}/api/surveys/${surveyId}/publish`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
});
|
||||
if (!pubRes.ok) throw new Error(`Publish failed: ${await pubRes.text()}`);
|
||||
if (!pubRes.ok) {throw new Error(`Publish failed: ${await pubRes.text()}`);}
|
||||
|
||||
console.log(`Survey created: ${slug} (${imported.questions.length} questions)`);
|
||||
return { slug, questions: imported.questions, surveyId };
|
||||
@@ -387,7 +383,7 @@ async function fetchActualRespondentCount(
|
||||
const res = await fetch(`${baseUrl}/api/surveys/${surveyId}/results`, {
|
||||
headers: { Cookie: sessionCookie },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {return null;}
|
||||
const data = (await res.json()) as { respondentCounts?: { total: number; completed: number } };
|
||||
return data.respondentCounts ?? null;
|
||||
} catch {
|
||||
@@ -436,29 +432,33 @@ function generateAnswer(q: Question): { questionId: string; value: string; other
|
||||
const count = Math.min(1 + Math.floor(Math.random() * 3), options.length);
|
||||
const shuffled = [...options].sort(() => Math.random() - 0.5);
|
||||
const selected = shuffled.slice(0, count).map((o) => o.value);
|
||||
if (q.has_other && Math.random() < 0.1) selected.push('Other');
|
||||
if (q.has_other && Math.random() < 0.1) {selected.push('Other');}
|
||||
return {
|
||||
...base,
|
||||
value: selected.join(','),
|
||||
...(selected.includes('Other') ? { otherText: 'Custom option' } : {}),
|
||||
...(selected.includes('Other') && { otherText: 'Custom option' }),
|
||||
};
|
||||
}
|
||||
case 'dropdown':
|
||||
case 'dropdown': {
|
||||
return { ...base, value: options.length > 0 ? randomItem(options).value : 'option1' };
|
||||
case 'text':
|
||||
}
|
||||
case 'text': {
|
||||
return { ...base, value: randomItem(NAMES) };
|
||||
case 'textarea':
|
||||
}
|
||||
case 'textarea': {
|
||||
return { ...base, value: randomItem(COMMENTS) };
|
||||
case 'email':
|
||||
return { ...base, value: `user${Math.floor(Math.random() * 100000)}@example.com` };
|
||||
}
|
||||
case 'email': {
|
||||
return { ...base, value: `user${Math.floor(Math.random() * 100_000)}@example.com` };
|
||||
}
|
||||
case 'number': {
|
||||
let min = 0,
|
||||
max = 100;
|
||||
if (q.config) {
|
||||
try {
|
||||
const cfg = JSON.parse(q.config);
|
||||
if (cfg.min != null) min = cfg.min;
|
||||
if (cfg.max != null) max = cfg.max;
|
||||
if (cfg.min != null) {min = cfg.min;}
|
||||
if (cfg.max != null) {max = cfg.max;}
|
||||
} catch {
|
||||
// Malformed config JSON — keep the default min/max.
|
||||
}
|
||||
@@ -470,19 +470,22 @@ function generateAnswer(q: Question): { questionId: string; value: string; other
|
||||
if (q.config) {
|
||||
try {
|
||||
const cfg = JSON.parse(q.config);
|
||||
if (cfg.scaleMax) scaleMax = cfg.scaleMax;
|
||||
if (cfg.scaleMax) {scaleMax = cfg.scaleMax;}
|
||||
} catch {
|
||||
// Malformed config JSON — keep the default scaleMax.
|
||||
}
|
||||
}
|
||||
return { ...base, value: String(1 + Math.floor(Math.random() * scaleMax)) };
|
||||
}
|
||||
case 'nps':
|
||||
case 'nps': {
|
||||
return { ...base, value: String(Math.floor(Math.random() * 11)) };
|
||||
case 'likert':
|
||||
}
|
||||
case 'likert': {
|
||||
return { ...base, value: randomItem(LIKERT) };
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return { ...base, value: 'test' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,10 +526,10 @@ function createWsClient(url: string, metrics: Metrics, initialCookie?: string):
|
||||
};
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
if (closed) {return;}
|
||||
const start = performance.now();
|
||||
const opts: ConstructorParameters<typeof WebSocketClass>[2] = {};
|
||||
if (cookie) opts.headers = { Cookie: cookie };
|
||||
if (cookie) {opts.headers = { Cookie: cookie };}
|
||||
ws = new WebSocketClass(url, opts);
|
||||
|
||||
// Capture Set-Cookie from the upgrade response so reconnects reuse the
|
||||
@@ -556,8 +559,8 @@ function createWsClient(url: string, metrics: Metrics, initialCookie?: string):
|
||||
const req = pending.get(msg.requestId);
|
||||
if (req) {
|
||||
pending.delete(msg.requestId);
|
||||
if (msg.error) req.reject(new Error(msg.error));
|
||||
else req.resolve(msg.data);
|
||||
if (msg.error) {req.reject(new Error(msg.error));}
|
||||
else {req.resolve(msg.data);}
|
||||
}
|
||||
}
|
||||
// push events (counts, stats, results) are ignored by respondent clients
|
||||
@@ -590,10 +593,10 @@ function createWsClient(url: string, metrics: Metrics, initialCookie?: string):
|
||||
});
|
||||
|
||||
ws.on('close', (code: number, reason: Buffer) => {
|
||||
for (const [, req] of pending) req.reject(new Error('WebSocket closed'));
|
||||
for (const [, req] of pending) {req.reject(new Error('WebSocket closed'));}
|
||||
pending.clear();
|
||||
|
||||
if (closed) return;
|
||||
if (closed) {return;}
|
||||
|
||||
const wasConnected = everConnected;
|
||||
everConnected = false;
|
||||
@@ -699,7 +702,7 @@ type UserProfile = {
|
||||
function createUserProfile(totalQuestions: number): UserProfile {
|
||||
const isFast = Math.random() < 0.4;
|
||||
const minDelay = isFast ? 500 : 3000;
|
||||
const maxDelay = isFast ? 3000 : 15000;
|
||||
const maxDelay = isFast ? 3000 : 15_000;
|
||||
|
||||
const willAbandon = Math.random() < 0.25;
|
||||
const abandonAt = willAbandon ? 1 + Math.floor(Math.random() * (totalQuestions - 1)) : null;
|
||||
@@ -767,9 +770,9 @@ async function simulateUser(
|
||||
|
||||
// Fetch survey metadata over HTTP first, exactly as the frontend does.
|
||||
const surveyRes = await timedFetch(metrics, 'GET /api/s/:slug', `${config.baseUrl}/api/s/${slug}`);
|
||||
if (!surveyRes || !surveyRes.ok) return outcome('load');
|
||||
if (!surveyRes || !surveyRes.ok) {return outcome('load');}
|
||||
await surveyRes.json();
|
||||
if (isExpired()) return outcome('none', { abandoned: true });
|
||||
if (isExpired()) {return outcome('none', { abandoned: true });}
|
||||
|
||||
// WS is the only data path: no HTTP fallback, because per-request cookie auth
|
||||
// would reduce the very server capacity this test measures.
|
||||
@@ -778,7 +781,7 @@ async function simulateUser(
|
||||
const waitForConnect = async (client: WsClient) => {
|
||||
// Wait up to ~13s to match the frontend's 3 retries × 3s delay + initial attempt
|
||||
for (let i = 0; i < 130; i++) {
|
||||
if (client.connected) return true;
|
||||
if (client.connected) {return true;}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
@@ -834,14 +837,16 @@ async function simulateUser(
|
||||
const MAX_FLUSH_FAILURES = 5;
|
||||
|
||||
const clearInactivity = () => {
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
if (!inactivityTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
};
|
||||
|
||||
const saveBatchWs = async (batch: PendingSave[]): Promise<boolean> => {
|
||||
if (!wsClient || !wsClient.connected) return false;
|
||||
if (!wsClient || !wsClient.connected) {return false;}
|
||||
try {
|
||||
await wsClient.request('submit-answers', { answers: batch });
|
||||
return true;
|
||||
@@ -851,8 +856,8 @@ async function simulateUser(
|
||||
};
|
||||
|
||||
const flushBuffer = async (): Promise<boolean> => {
|
||||
if (flushing) return flushing;
|
||||
if (buffer.size === 0) return true;
|
||||
if (flushing) {return flushing;}
|
||||
if (buffer.size === 0) {return true;}
|
||||
clearInactivity();
|
||||
const batch = [...buffer.values()];
|
||||
buffer.clear();
|
||||
@@ -865,7 +870,7 @@ async function simulateUser(
|
||||
consecutiveFailures++;
|
||||
// Restore unsent answers so the next flush retries them
|
||||
for (const item of batch) {
|
||||
if (!buffer.has(item.questionId)) buffer.set(item.questionId, item);
|
||||
if (!buffer.has(item.questionId)) {buffer.set(item.questionId, item);}
|
||||
}
|
||||
}
|
||||
return success;
|
||||
@@ -894,13 +899,13 @@ async function simulateUser(
|
||||
// ms on answer, so the backend can populate per-question timing analytics.
|
||||
const answerQuestions = async (from: number, to: number) => {
|
||||
for (let i = from; i < to; i++) {
|
||||
if (isExpired()) break;
|
||||
if (profile.abandonAt !== null && questionsAnswered >= profile.abandonAt) break;
|
||||
if (consecutiveFailures >= MAX_FLUSH_FAILURES) break;
|
||||
if (isExpired()) {break;}
|
||||
if (profile.abandonAt !== null && questionsAnswered >= profile.abandonAt) {break;}
|
||||
if (consecutiveFailures >= MAX_FLUSH_FAILURES) {break;}
|
||||
|
||||
const shownAt = Date.now();
|
||||
await randomDelay(profile.minDelay, profile.maxDelay);
|
||||
if (isExpired()) break;
|
||||
if (isExpired()) {break;}
|
||||
|
||||
const answer = generateAnswer(questions[i]);
|
||||
bufferAnswer({ ...answer, answerMs: Date.now() - shownAt });
|
||||
@@ -908,9 +913,9 @@ async function simulateUser(
|
||||
}
|
||||
// Final flush — retry a few times in case WS is still reconnecting.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (buffer.size === 0) break;
|
||||
if (buffer.size === 0) {break;}
|
||||
const ok = await flushBuffer();
|
||||
if (ok) break;
|
||||
if (ok) {break;}
|
||||
await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[Math.min(attempt, BACKOFF_DELAYS.length - 1)]));
|
||||
}
|
||||
};
|
||||
@@ -925,7 +930,7 @@ async function simulateUser(
|
||||
// server recognises the same respondent instead of creating a new one.
|
||||
wsClient.close();
|
||||
wsClient = null;
|
||||
await randomDelay(5000, 20000);
|
||||
await randomDelay(5000, 20_000);
|
||||
|
||||
if (!isExpired()) {
|
||||
wsClient = createWsClient(wsUrl, metrics, respondentCookie);
|
||||
@@ -1053,7 +1058,7 @@ async function main() {
|
||||
metrics.report(actualCounts);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Fatal error:', e);
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -156,30 +156,39 @@ function generateAnswer(
|
||||
fillLevel: 'short' | 'medium' | 'long',
|
||||
): { value: string; otherText: string | null } {
|
||||
switch (question.type) {
|
||||
case 'text':
|
||||
case 'text': {
|
||||
return { value: 'John Doe', otherText: null };
|
||||
case 'email':
|
||||
}
|
||||
case 'email': {
|
||||
return { value: 'user@example.com', otherText: null };
|
||||
}
|
||||
case 'textarea': {
|
||||
const base = 'This is a response about my experience. ';
|
||||
const repeats = fillLevel === 'short' ? 1 : fillLevel === 'medium' ? 5 : 20;
|
||||
return { value: base.repeat(repeats), otherText: null };
|
||||
}
|
||||
case 'radio':
|
||||
case 'dropdown':
|
||||
case 'dropdown': {
|
||||
return { value: 'opt2', otherText: null };
|
||||
case 'checkbox':
|
||||
}
|
||||
case 'checkbox': {
|
||||
return { value: 'opt1,opt3', otherText: null };
|
||||
case 'rating':
|
||||
}
|
||||
case 'rating': {
|
||||
return { value: '4', otherText: null };
|
||||
case 'nps':
|
||||
}
|
||||
case 'nps': {
|
||||
return { value: '8', otherText: null };
|
||||
case 'number':
|
||||
}
|
||||
case 'number': {
|
||||
return { value: '42', otherText: null };
|
||||
case 'likert':
|
||||
}
|
||||
case 'likert': {
|
||||
return { value: 'Agree', otherText: null };
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return { value: 'test', otherText: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +212,7 @@ function generateTallies(questions: QuestionRow[], uniqueValuesPerQuestion: numb
|
||||
const tallies = new Map<string, AnswerTally[]>();
|
||||
const choiceTypes = new Set(['radio', 'checkbox', 'dropdown', 'rating', 'nps', 'likert']);
|
||||
for (const q of questions) {
|
||||
if (!choiceTypes.has(q.type)) continue;
|
||||
if (!choiceTypes.has(q.type)) {continue;}
|
||||
const tally: AnswerTally[] = [];
|
||||
for (let i = 0; i < uniqueValuesPerQuestion; i++) {
|
||||
tally.push({ value: `value_${i}`, otherText: null, count: Math.floor(Math.random() * 1000) });
|
||||
@@ -214,20 +223,20 @@ function generateTallies(questions: QuestionRow[], uniqueValuesPerQuestion: numb
|
||||
}
|
||||
|
||||
function measure<T>(label: string, setup: () => T): { label: string; result: T; bytes: number } {
|
||||
if (global.gc) global.gc();
|
||||
if (globalThis.gc) {globalThis.gc();}
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
|
||||
const result = setup();
|
||||
|
||||
if (global.gc) global.gc();
|
||||
if (globalThis.gc) {globalThis.gc();}
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
|
||||
return { label, result, bytes: after - before };
|
||||
}
|
||||
|
||||
function fmt(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024) {return `${bytes} B`;}
|
||||
if (bytes < 1024 * 1024) {return `${(bytes / 1024).toFixed(1)} KB`;}
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
@@ -244,7 +253,7 @@ const SCENARIOS: Scenario[] = [
|
||||
{ name: 'Large (50q, long textareas)', questionCount: 50, sectionCount: 10, fillLevel: 'long' },
|
||||
];
|
||||
|
||||
const CONCURRENT_COUNTS = [100, 1000, 5000, 10000];
|
||||
const CONCURRENT_COUNTS = [100, 1000, 5000, 10_000];
|
||||
|
||||
function profileScenario(scenario: Scenario) {
|
||||
console.log(`\n${'='.repeat(70)}`);
|
||||
@@ -276,7 +285,7 @@ function profileScenario(scenario: Scenario) {
|
||||
console.log(` ${fmt(singleRespondent.bytes)}`);
|
||||
|
||||
console.log(`\n Concurrent respondent scaling:`);
|
||||
console.log(` ${'Users'.padEnd(10)} ${'Total cache'.padEnd(15)} ${'Per-user avg'.padEnd(15)} ${'% of 100MB'}`);
|
||||
console.log(` ${'Users'.padEnd(10)} ${'Total cache'.padEnd(15)} ${'Per-user avg'.padEnd(15)} % of 100MB`);
|
||||
|
||||
for (const count of CONCURRENT_COUNTS) {
|
||||
const concurrent = measure(`${count} concurrent`, () => {
|
||||
@@ -307,7 +316,7 @@ console.log('Survey DO Memory Profiler');
|
||||
console.log(`Node ${process.version} | V8 heap used: ${fmt(process.memoryUsage().heapUsed)}`);
|
||||
console.log(`Budget: 128 MB total DO memory (assume ~100 MB usable after runtime overhead)`);
|
||||
|
||||
if (!global.gc) {
|
||||
if (!globalThis.gc) {
|
||||
console.log('\n⚠ For accurate measurements, run with: node --expose-gc --import tsx scripts/memory-profile.ts');
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@ export const ROLE_HIERARCHY: Record<string, number> = { admin: 3, editor: 2, vie
|
||||
*/
|
||||
export const MAX_ANSWER_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Returns null for missing/invalid input so callers store NULL rather than poisoning aggregates. */
|
||||
/**
|
||||
Returns null for missing/invalid input so callers store NULL rather than poisoning aggregates.
|
||||
*/
|
||||
export function clampAnswerMs(raw: unknown): number | null {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return null;
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {return null;}
|
||||
return Math.min(Math.floor(raw), MAX_ANSWER_MS);
|
||||
}
|
||||
|
||||
@@ -67,9 +69,11 @@ export const COMPLETION_TIME_BUCKETS: ReadonlyArray<{
|
||||
{ label: '>1h', minSeconds: 3600, maxSeconds: null },
|
||||
];
|
||||
|
||||
/** Nearest-rank percentile; `sorted` MUST already be ascending. Null when empty. */
|
||||
/**
|
||||
Nearest-rank percentile; `sorted` MUST already be ascending. Null when empty.
|
||||
*/
|
||||
export function percentile(sorted: number[], p: number): number | null {
|
||||
if (sorted.length === 0) return null;
|
||||
if (sorted.length === 0) {return null;}
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((p / 100) * (sorted.length - 1))));
|
||||
return sorted[idx];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function getCookie(
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const header = request.headers.get('Cookie') ?? '';
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
const match = header.match(new RegExp(String.raw`(?:^|;\s*)${name}=([^;]+)`));
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ export interface AnswersTable {
|
||||
answer: string;
|
||||
other_text: string | null;
|
||||
answered_at: string;
|
||||
/** Milliseconds the respondent spent on this question before committing. */
|
||||
/**
|
||||
Milliseconds the respondent spent on this question before committing.
|
||||
*/
|
||||
answer_ms: number | null;
|
||||
}
|
||||
|
||||
@@ -126,8 +128,8 @@ export interface DbConfig {
|
||||
}
|
||||
|
||||
export function detectDbType(url?: string): DbType {
|
||||
if (!url) return 'sqlite';
|
||||
if (url.startsWith('postgres://') || url.startsWith('postgresql://')) return 'postgres';
|
||||
if (!url) {return 'sqlite';}
|
||||
if (url.startsWith('postgres://') || url.startsWith('postgresql://')) {return 'postgres';}
|
||||
return 'sqlite';
|
||||
}
|
||||
|
||||
|
||||
@@ -43,16 +43,20 @@ export class SurveyCache {
|
||||
*/
|
||||
private _respondentState = new Map<string, RespondentState>();
|
||||
|
||||
/** Debounce flag for scheduled broadcasts — shared between ws-handler and survey-do */
|
||||
/**
|
||||
Debounce flag for scheduled broadcasts — shared between ws-handler and survey-do
|
||||
*/
|
||||
readonly broadcastScheduled = { value: false };
|
||||
|
||||
/** Fast-tier alarm tick counter, held in memory rather than DO storage (see SLOW_TICKS_PER_CYCLE). */
|
||||
/**
|
||||
Fast-tier alarm tick counter, held in memory rather than DO storage (see SLOW_TICKS_PER_CYCLE).
|
||||
*/
|
||||
fastTick = 0;
|
||||
|
||||
constructor(private sql: SqlStorage) {}
|
||||
|
||||
hasRespondent(id: string): boolean {
|
||||
if (this._respondentState.has(id)) return true;
|
||||
if (this._respondentState.has(id)) {return true;}
|
||||
const row = this.sql.exec('SELECT 1 FROM respondents WHERE id = ? LIMIT 1', id).toArray()[0];
|
||||
return !!row;
|
||||
}
|
||||
@@ -61,14 +65,16 @@ export class SurveyCache {
|
||||
this._respondentState.set(id, { isComplete: false, hasSubmitted: false, choiceAnswers: new Map() });
|
||||
}
|
||||
|
||||
/** Callers needing text answers must query SQL — only choice answers are cached. */
|
||||
/**
|
||||
Callers needing text answers must query SQL — only choice answers are cached.
|
||||
*/
|
||||
getCachedRespondent(id: string): RespondentState | undefined {
|
||||
return this._respondentState.get(id);
|
||||
}
|
||||
|
||||
setAnswer(respondentId: string, questionId: string, value: string, otherText: string | null): void {
|
||||
const state = this._respondentState.get(respondentId);
|
||||
if (!state) return;
|
||||
if (!state) {return;}
|
||||
state.hasSubmitted = true;
|
||||
if (this.choiceQuestionIds.has(questionId)) {
|
||||
state.choiceAnswers.set(questionId, { value, otherText });
|
||||
@@ -77,7 +83,7 @@ export class SurveyCache {
|
||||
|
||||
markRespondentComplete(id: string): void {
|
||||
const state = this._respondentState.get(id);
|
||||
if (state) state.isComplete = true;
|
||||
if (state) {state.isComplete = true;}
|
||||
}
|
||||
|
||||
removeRespondent(id: string): void {
|
||||
@@ -85,15 +91,15 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
get survey(): SurveyRow {
|
||||
if (this._survey) return this._survey;
|
||||
if (this._survey) {return this._survey;}
|
||||
const rows = this.sql.exec('SELECT * FROM surveys LIMIT 1').toArray();
|
||||
if (rows.length === 0) throw new ServiceError('Survey not found', 404);
|
||||
if (rows.length === 0) {throw new ServiceError('Survey not found', 404);}
|
||||
this._survey = rows[0] as unknown as SurveyRow;
|
||||
return this._survey;
|
||||
}
|
||||
|
||||
get sections(): SectionRow[] {
|
||||
if (this._sections) return this._sections;
|
||||
if (this._sections) {return this._sections;}
|
||||
this._sections = this.sql
|
||||
.exec('SELECT * FROM survey_sections ORDER BY sort_order')
|
||||
.toArray() as unknown as SectionRow[];
|
||||
@@ -101,7 +107,7 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
get questions(): QuestionRow[] {
|
||||
if (this._questions) return this._questions;
|
||||
if (this._questions) {return this._questions;}
|
||||
this._questions = this.sql
|
||||
.exec('SELECT * FROM survey_questions ORDER BY sort_order')
|
||||
.toArray() as unknown as QuestionRow[];
|
||||
@@ -109,13 +115,13 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
get choiceQuestionIds(): Set<string> {
|
||||
if (this._choiceQuestionIds) return this._choiceQuestionIds;
|
||||
if (this._choiceQuestionIds) {return this._choiceQuestionIds;}
|
||||
this._choiceQuestionIds = new Set(this.questions.filter((q) => CHOICE_TYPES.has(q.type)).map((q) => q.id));
|
||||
return this._choiceQuestionIds;
|
||||
}
|
||||
|
||||
get counters(): { total: number; completed: number } {
|
||||
if (this._counters) return this._counters;
|
||||
if (this._counters) {return this._counters;}
|
||||
const row = this.sql
|
||||
.exec(`SELECT COUNT(*) as total, SUM(CASE WHEN is_complete = 1 THEN 1 ELSE 0 END) as completed FROM respondents`)
|
||||
.toArray()[0];
|
||||
@@ -124,10 +130,10 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
get tallies(): Map<string, AnswerTally[]> {
|
||||
if (this._tallies) return this._tallies;
|
||||
if (this._tallies) {return this._tallies;}
|
||||
const choiceIds = this.questions.filter((q) => CHOICE_TYPES.has(q.type)).map((q) => q.id);
|
||||
this._tallies = new Map();
|
||||
if (choiceIds.length === 0) return this._tallies;
|
||||
if (choiceIds.length === 0) {return this._tallies;}
|
||||
|
||||
const placeholders = choiceIds.map(() => '?').join(',');
|
||||
const rows = this.sql
|
||||
@@ -142,7 +148,7 @@ export class SurveyCache {
|
||||
|
||||
for (const row of rows) {
|
||||
const qId = row.question_id as string;
|
||||
if (!this._tallies.has(qId)) this._tallies.set(qId, []);
|
||||
if (!this._tallies.has(qId)) {this._tallies.set(qId, []);}
|
||||
this._tallies.get(qId)!.push({
|
||||
value: row.answer as string,
|
||||
otherText: (row.other_text as string) || null,
|
||||
@@ -153,7 +159,7 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
get hasSurvey(): boolean {
|
||||
if (this._survey) return true;
|
||||
if (this._survey) {return true;}
|
||||
const rows = this.sql.exec('SELECT id FROM surveys LIMIT 1').toArray();
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -172,11 +178,11 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
incrementTotal(): void {
|
||||
if (this._counters) this._counters.total++;
|
||||
if (this._counters) {this._counters.total++;}
|
||||
}
|
||||
|
||||
incrementCompleted(): void {
|
||||
if (this._counters) this._counters.completed++;
|
||||
if (this._counters) {this._counters.completed++;}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,7 +193,7 @@ export class SurveyCache {
|
||||
* and the live charts silently drift.
|
||||
*/
|
||||
updateTalliesOnCompletion(respondentId: string): void {
|
||||
if (!this._tallies) return;
|
||||
if (!this._tallies) {return;}
|
||||
const state = this._respondentState.get(respondentId);
|
||||
if (!state) {
|
||||
this._tallies = null;
|
||||
@@ -195,7 +201,7 @@ export class SurveyCache {
|
||||
}
|
||||
|
||||
for (const [qId, ans] of state.choiceAnswers) {
|
||||
if (!this._tallies.has(qId)) this._tallies.set(qId, []);
|
||||
if (!this._tallies.has(qId)) {this._tallies.set(qId, []);}
|
||||
const qTallies = this._tallies.get(qId)!;
|
||||
const existing = qTallies.find((t) => t.value === ans.value && t.otherText === ans.otherText);
|
||||
if (existing) {
|
||||
|
||||
@@ -71,8 +71,9 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case 'get-results':
|
||||
case 'get-results': {
|
||||
return ctx.respondentService.getResults(surveyId);
|
||||
}
|
||||
|
||||
case 'get-timeline': {
|
||||
const raw = data.granularity;
|
||||
@@ -80,14 +81,17 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
|
||||
return ctx.respondentService.getTimeline(surveyId, granularity);
|
||||
}
|
||||
|
||||
case 'get-completion-times':
|
||||
case 'get-completion-times': {
|
||||
return ctx.respondentService.getCompletionTimes(surveyId);
|
||||
}
|
||||
|
||||
case 'get-question-timings':
|
||||
case 'get-question-timings': {
|
||||
return ctx.respondentService.getQuestionTimings(surveyId);
|
||||
}
|
||||
|
||||
case 'get-dropoff':
|
||||
case 'get-dropoff': {
|
||||
return ctx.respondentService.getDropoff(surveyId);
|
||||
}
|
||||
|
||||
case 'list-respondents': {
|
||||
const offset = Number(data.offset ?? 0);
|
||||
@@ -95,8 +99,9 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
|
||||
return ctx.respondentService.listRespondents(surveyId, offset, limit);
|
||||
}
|
||||
|
||||
case 'get-respondent':
|
||||
case 'get-respondent': {
|
||||
return ctx.respondentService.getRespondentDetail(surveyId, data.respondentId as string);
|
||||
}
|
||||
|
||||
case 'delete-respondent': {
|
||||
await ctx.respondentService.deleteRespondent(surveyId, data.respondentId as string);
|
||||
@@ -117,10 +122,12 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
|
||||
});
|
||||
}
|
||||
|
||||
case 'export-definition':
|
||||
case 'export-definition': {
|
||||
return ctx.surveyService.exportDefinition(surveyId);
|
||||
}
|
||||
|
||||
default:
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Table names must match the main Database interface so the shared Kysely queries work unchanged. */
|
||||
/**
|
||||
Table names must match the main Database interface so the shared Kysely queries work unchanged.
|
||||
*/
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS surveys (
|
||||
@@ -76,7 +78,9 @@ CREATE TABLE IF NOT EXISTS answers (
|
||||
|
||||
const initializedInstances = new WeakSet<SqlStorage>();
|
||||
|
||||
/** Additive migrations, replayed on every boot — there is no version table, so each must be safe to re-run. */
|
||||
/**
|
||||
Additive migrations, replayed on every boot — there is no version table, so each must be safe to re-run.
|
||||
*/
|
||||
const MIGRATIONS: string[] = ['ALTER TABLE answers ADD COLUMN answer_ms INTEGER'];
|
||||
|
||||
function isAlreadyAppliedError(e: unknown): boolean {
|
||||
@@ -89,14 +93,14 @@ function applySchema(sql: SqlStorage): void {
|
||||
for (const stmt of MIGRATIONS) {
|
||||
try {
|
||||
sql.exec(stmt);
|
||||
} catch (e) {
|
||||
if (!isAlreadyAppliedError(e)) throw e;
|
||||
} catch (error) {
|
||||
if (!isAlreadyAppliedError(error)) {throw error;}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSchema(sql: SqlStorage): void {
|
||||
if (initializedInstances.has(sql)) return;
|
||||
if (initializedInstances.has(sql)) {return;}
|
||||
applySchema(sql);
|
||||
initializedInstances.add(sql);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ export class SurveyDO extends DurableObject {
|
||||
|
||||
try {
|
||||
return await this.handleHttp(request, url);
|
||||
} catch (e) {
|
||||
if (e instanceof ServiceError) {
|
||||
return Response.json({ error: e.message }, { status: e.status });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
return Response.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error('SurveyDO error:', e);
|
||||
console.error('SurveyDO error:', error);
|
||||
return Response.json({ error: 'Internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export class SurveyDO extends DurableObject {
|
||||
const [client, server] = Object.values(pair);
|
||||
|
||||
const tags: string[] = [type, `role:${verifiedRole}`];
|
||||
if (respondentId) tags.push(`rid:${respondentId}`);
|
||||
if (respondentId) {tags.push(`rid:${respondentId}`);}
|
||||
this.ctx.acceptWebSocket(server, tags);
|
||||
|
||||
const counts = getPresenceCounts(this.ctx);
|
||||
@@ -123,13 +123,13 @@ export class SurveyDO extends DurableObject {
|
||||
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
|
||||
|
||||
const headers = new Headers();
|
||||
if (setCookieHeader) headers.set('Set-Cookie', setCookieHeader);
|
||||
if (setCookieHeader) {headers.set('Set-Cookie', setCookieHeader);}
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client, headers });
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
if (typeof message !== 'string') return;
|
||||
if (typeof message !== 'string') {return;}
|
||||
const services = {
|
||||
survey: this.surveyService,
|
||||
respondent: this.respondentService,
|
||||
@@ -155,8 +155,8 @@ export class SurveyDO extends DurableObject {
|
||||
this.cache.fastTick = 0;
|
||||
if (this.ctx.getWebSockets('viewer').length > 0 && this.cache.hasSurvey) {
|
||||
// Fire-and-forget: we don't block the next fast broadcast on analytics.
|
||||
broadcastSlowAnalytics(this.ctx, this.cache.survey.id, this.respondentService).catch((e) => {
|
||||
console.error('slow analytics broadcast failed:', e);
|
||||
broadcastSlowAnalytics(this.ctx, this.cache.survey.id, this.respondentService).catch((error) => {
|
||||
console.error('slow analytics broadcast failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -175,25 +175,25 @@ export class SurveyDO extends DurableObject {
|
||||
const method = request.method;
|
||||
|
||||
// Init (called by API worker on survey creation)
|
||||
if (method === 'POST' && path === '/init') return this.handleInit(request);
|
||||
if (method === 'POST' && path === '/init') {return this.handleInit(request);}
|
||||
|
||||
// Survey CRUD (stays HTTP for D1 catalog sync)
|
||||
if ((path === '/' || path === '') && method === 'GET') return this.handleGetSurvey();
|
||||
if ((path === '/' || path === '') && method === 'PUT') return this.handleUpdateSurvey(request);
|
||||
if ((path === '/' || path === '') && method === 'DELETE') return this.handleDeleteSurvey();
|
||||
if (method === 'PUT' && path === '/publish') return this.handlePublish();
|
||||
if (method === 'PUT' && path === '/unpublish') return this.handleUnpublish();
|
||||
if (method === 'PUT' && path === '/archive') return this.handleArchive();
|
||||
if (method === 'PUT' && path === '/unarchive') return this.handleUnarchive();
|
||||
if (method === 'POST' && path === '/duplicate') return this.handleDuplicate();
|
||||
if ((path === '/' || path === '') && method === 'GET') {return this.handleGetSurvey();}
|
||||
if ((path === '/' || path === '') && method === 'PUT') {return this.handleUpdateSurvey(request);}
|
||||
if ((path === '/' || path === '') && method === 'DELETE') {return this.handleDeleteSurvey();}
|
||||
if (method === 'PUT' && path === '/publish') {return this.handlePublish();}
|
||||
if (method === 'PUT' && path === '/unpublish') {return this.handleUnpublish();}
|
||||
if (method === 'PUT' && path === '/archive') {return this.handleArchive();}
|
||||
if (method === 'PUT' && path === '/unarchive') {return this.handleUnarchive();}
|
||||
if (method === 'POST' && path === '/duplicate') {return this.handleDuplicate();}
|
||||
|
||||
if (method === 'GET' && path === '/results/export') return this.handleExportResults(url);
|
||||
if (method === 'GET' && path === '/results/export') {return this.handleExportResults(url);}
|
||||
|
||||
// Public respondent (cookie-setting endpoints stay HTTP)
|
||||
if (method === 'GET' && path === '/public') return this.handleGetPublicSurvey(request);
|
||||
if (method === 'GET' && path === '/public/resume') return this.handleResume(request);
|
||||
if (method === 'POST' && path === '/public/answers/batch') return this.handleSubmitAnswers(request);
|
||||
if (method === 'POST' && path === '/public/complete') return this.handleComplete(request);
|
||||
if (method === 'GET' && path === '/public') {return this.handleGetPublicSurvey(request);}
|
||||
if (method === 'GET' && path === '/public/resume') {return this.handleResume(request);}
|
||||
if (method === 'POST' && path === '/public/answers/batch') {return this.handleSubmitAnswers(request);}
|
||||
if (method === 'POST' && path === '/public/complete') {return this.handleComplete(request);}
|
||||
|
||||
return this.handleSelfHostedFallback(method, path, request, url);
|
||||
}
|
||||
@@ -298,7 +298,9 @@ export class SurveyDO extends DurableObject {
|
||||
try {
|
||||
ws.close(1000, 'Survey deleted');
|
||||
} catch {
|
||||
/* ignore */
|
||||
/*
|
||||
ignore
|
||||
*/
|
||||
}
|
||||
}
|
||||
// deleteAll() drops the SQLite tables too, and this same live instance serves
|
||||
@@ -446,7 +448,7 @@ export class SurveyDO extends DurableObject {
|
||||
return Response.json({ error: 'Authentication required' }, { status: 403 });
|
||||
}
|
||||
const respondentId = request.headers.get('X-Respondent-Id');
|
||||
if (!respondentId) return Response.json({ error: 'No respondent cookie' }, { status: 400 });
|
||||
if (!respondentId) {return Response.json({ error: 'No respondent cookie' }, { status: 400 });}
|
||||
const { answers } = (await request.json()) as {
|
||||
answers: Array<{ questionId: string; value: string; otherText?: string; answerMs?: number }>;
|
||||
};
|
||||
@@ -467,7 +469,7 @@ export class SurveyDO extends DurableObject {
|
||||
return Response.json({ error: 'Authentication required' }, { status: 403 });
|
||||
}
|
||||
const respondentId = request.headers.get('X-Respondent-Id');
|
||||
if (!respondentId) return Response.json({ error: 'No respondent cookie' }, { status: 400 });
|
||||
if (!respondentId) {return Response.json({ error: 'No respondent cookie' }, { status: 400 });}
|
||||
const completed = await this.respondentService.complete(survey.slug!, respondentId, survey);
|
||||
if (completed) {
|
||||
this.cache.incrementCompleted();
|
||||
@@ -481,7 +483,7 @@ export class SurveyDO extends DurableObject {
|
||||
const route = this.matchFallbackRoute(method, path, url);
|
||||
if (!route) {
|
||||
// Live results has transport-specific ETag handling
|
||||
if (method === 'GET' && path === '/results/live') return this.handleLiveResults(request);
|
||||
if (method === 'GET' && path === '/results/live') {return this.handleLiveResults(request);}
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
@@ -489,7 +491,7 @@ export class SurveyDO extends DurableObject {
|
||||
const data = { ...route.params, ...body };
|
||||
const result = await execute(route.op, data, this.commandContext());
|
||||
|
||||
if (result === undefined) return new Response(null, { status: 204 });
|
||||
if (result === undefined) {return new Response(null, { status: 204 });}
|
||||
return Response.json(result, { status: route.status ?? 200 });
|
||||
}
|
||||
|
||||
@@ -499,36 +501,36 @@ export class SurveyDO extends DurableObject {
|
||||
url: URL,
|
||||
): { op: string; params: Record<string, unknown>; status?: number } | null {
|
||||
// Sections — reorder before /:id pattern
|
||||
if (method === 'POST' && path === '/sections') return { op: 'create-section', params: {}, status: 201 };
|
||||
if (method === 'PUT' && path === '/sections/reorder') return { op: 'reorder-sections', params: {} };
|
||||
if (method === 'POST' && path === '/sections') {return { op: 'create-section', params: {}, status: 201 };}
|
||||
if (method === 'PUT' && path === '/sections/reorder') {return { op: 'reorder-sections', params: {} };}
|
||||
let match = path.match(/^\/sections\/([^/]+)$/);
|
||||
if (match && method === 'PUT') return { op: 'update-section', params: { id: match[1] } };
|
||||
if (match && method === 'DELETE') return { op: 'delete-section', params: { id: match[1] } };
|
||||
if (match && method === 'PUT') {return { op: 'update-section', params: { id: match[1] } };}
|
||||
if (match && method === 'DELETE') {return { op: 'delete-section', params: { id: match[1] } };}
|
||||
|
||||
match = path.match(/^\/sections\/([^/]+)\/questions\/reorder$/);
|
||||
if (match && method === 'PUT') return { op: 'reorder-questions', params: { sectionId: match[1] } };
|
||||
if (match && method === 'PUT') {return { op: 'reorder-questions', params: { sectionId: match[1] } };}
|
||||
match = path.match(/^\/sections\/([^/]+)\/questions$/);
|
||||
if (match && method === 'POST') return { op: 'create-question', params: { sectionId: match[1] }, status: 201 };
|
||||
if (match && method === 'POST') {return { op: 'create-question', params: { sectionId: match[1] }, status: 201 };}
|
||||
match = path.match(/^\/questions\/([^/]+)$/);
|
||||
if (match && method === 'PUT') return { op: 'update-question', params: { id: match[1] } };
|
||||
if (match && method === 'DELETE') return { op: 'delete-question', params: { id: match[1] } };
|
||||
if (match && method === 'PUT') {return { op: 'update-question', params: { id: match[1] } };}
|
||||
if (match && method === 'DELETE') {return { op: 'delete-question', params: { id: match[1] } };}
|
||||
|
||||
if (method === 'GET' && path === '/results') return { op: 'get-results', params: {} };
|
||||
if (method === 'GET' && path === '/results') {return { op: 'get-results', params: {} };}
|
||||
if (method === 'GET' && path === '/results/timeline')
|
||||
return { op: 'get-timeline', params: { granularity: url.searchParams.get('granularity') } };
|
||||
if (method === 'GET' && path === '/results/completion-times') return { op: 'get-completion-times', params: {} };
|
||||
if (method === 'GET' && path === '/results/question-timings') return { op: 'get-question-timings', params: {} };
|
||||
if (method === 'GET' && path === '/results/dropoff') return { op: 'get-dropoff', params: {} };
|
||||
{return { op: 'get-timeline', params: { granularity: url.searchParams.get('granularity') } };}
|
||||
if (method === 'GET' && path === '/results/completion-times') {return { op: 'get-completion-times', params: {} };}
|
||||
if (method === 'GET' && path === '/results/question-timings') {return { op: 'get-question-timings', params: {} };}
|
||||
if (method === 'GET' && path === '/results/dropoff') {return { op: 'get-dropoff', params: {} };}
|
||||
if (method === 'GET' && path === '/results/respondents')
|
||||
return {
|
||||
{return {
|
||||
op: 'list-respondents',
|
||||
params: { offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') },
|
||||
};
|
||||
};}
|
||||
match = path.match(/^\/results\/respondents\/([^/]+)$/);
|
||||
if (match && method === 'GET') return { op: 'get-respondent', params: { respondentId: match[1] } };
|
||||
if (match && method === 'DELETE') return { op: 'delete-respondent', params: { respondentId: match[1] } };
|
||||
if (match && method === 'GET') {return { op: 'get-respondent', params: { respondentId: match[1] } };}
|
||||
if (match && method === 'DELETE') {return { op: 'delete-respondent', params: { respondentId: match[1] } };}
|
||||
if (method === 'GET' && path === '/results/search')
|
||||
return {
|
||||
{return {
|
||||
op: 'search-answers',
|
||||
params: {
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
@@ -536,9 +538,9 @@ export class SurveyDO extends DurableObject {
|
||||
offset: url.searchParams.get('offset'),
|
||||
limit: url.searchParams.get('limit'),
|
||||
},
|
||||
};
|
||||
};}
|
||||
|
||||
if (method === 'GET' && path === '/definition') return { op: 'export-definition', params: {} };
|
||||
if (method === 'GET' && path === '/definition') {return { op: 'export-definition', params: {} };}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -547,7 +549,7 @@ export class SurveyDO extends DurableObject {
|
||||
const presence = getPresenceCounts(this.ctx).data;
|
||||
const results = await this.respondentService.getLiveResults(this.cache.survey.id, presence);
|
||||
const etag = `"${results.respondentCounts.completed}-${results.respondentCounts.total}"`;
|
||||
if (request.headers.get('If-None-Match') === etag) return new Response(null, { status: 304 });
|
||||
if (request.headers.get('If-None-Match') === 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');
|
||||
@@ -564,16 +566,16 @@ export class SurveyDO extends DurableObject {
|
||||
|
||||
private internalPath(pathname: string): string {
|
||||
const surveyMatch = pathname.match(SURVEY_ID_PATTERN);
|
||||
if (surveyMatch) return surveyMatch[2] || '/';
|
||||
if (surveyMatch) {return surveyMatch[2] || '/';}
|
||||
const publicMatch = pathname.match(PUBLIC_PATTERN);
|
||||
if (publicMatch) return '/public' + (publicMatch[2] || '');
|
||||
if (publicMatch) {return '/public' + (publicMatch[2] || '');}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
private passwordGateAllows(request: Request): boolean {
|
||||
const survey = this.cache.survey;
|
||||
if (!survey.password_hash) return true;
|
||||
if (request.headers.get('X-Authenticated') !== 'true') return false;
|
||||
if (!survey.password_hash) {return true;}
|
||||
if (request.headers.get('X-Authenticated') !== 'true') {return false;}
|
||||
return fingerprintMatchesPassword(request.headers.get('X-Survey-Pw-Fp'), survey.password_hash);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function getPresenceCounts(ctx: DurableObjectState): {
|
||||
|
||||
export function broadcastToViewers(ctx: DurableObjectState, cache: SurveyCache): void {
|
||||
const viewers = ctx.getWebSockets('viewer');
|
||||
if (viewers.length === 0) return;
|
||||
if (viewers.length === 0) {return;}
|
||||
|
||||
const counts = getPresenceCounts(ctx);
|
||||
const counters = cache.counters;
|
||||
@@ -67,7 +67,7 @@ export function broadcastToViewers(ctx: DurableObjectState, cache: SurveyCache):
|
||||
}
|
||||
|
||||
export function scheduleBroadcast(ctx: DurableObjectState, scheduled: { value: boolean }): void {
|
||||
if (scheduled.value) return;
|
||||
if (scheduled.value) {return;}
|
||||
scheduled.value = true;
|
||||
ctx.storage.setAlarm(Date.now() + BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export async function broadcastSlowAnalytics(
|
||||
respondents: RespondentService,
|
||||
): Promise<void> {
|
||||
const viewers = ctx.getWebSockets('viewer');
|
||||
if (viewers.length === 0) return;
|
||||
if (viewers.length === 0) {return;}
|
||||
|
||||
const [timeline, dropoff, completionTimes, questionTimings] = await Promise.all([
|
||||
respondents.getTimeline(surveyId, 'minute'),
|
||||
|
||||
@@ -71,7 +71,7 @@ const OP_ROLES: Record<string, MinRole> = {
|
||||
|
||||
function getWsRole(ws: WebSocket, ctx: DurableObjectState): string {
|
||||
for (const tag of ctx.getTags(ws)) {
|
||||
if (tag.startsWith('role:')) return tag.slice(5);
|
||||
if (tag.startsWith('role:')) {return tag.slice(5);}
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
@@ -82,7 +82,7 @@ function hasMinRole(ws: WebSocket, ctx: DurableObjectState, minRole: string): bo
|
||||
|
||||
function getWsRespondentId(ws: WebSocket, ctx: DurableObjectState): string | null {
|
||||
for (const tag of ctx.getTags(ws)) {
|
||||
if (tag.startsWith('rid:')) return tag.slice(4);
|
||||
if (tag.startsWith('rid:')) {return tag.slice(4);}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -128,8 +128,8 @@ export async function dispatch(
|
||||
try {
|
||||
const result = await handleOp(ws, op as keyof WsOperations, d, services, cache, ctx);
|
||||
respond(ws, requestId, op, result);
|
||||
} catch (e) {
|
||||
const message = e instanceof ServiceError ? e.message : e instanceof Error ? e.message : 'Internal error';
|
||||
} catch (error) {
|
||||
const message = (error instanceof ServiceError) || (error instanceof Error) ? error.message : 'Internal error';
|
||||
respondError(ws, requestId, op, message);
|
||||
}
|
||||
}
|
||||
@@ -150,22 +150,24 @@ async function handleOp(
|
||||
return { ...detail, survey: toClientSurvey(detail.survey) };
|
||||
}
|
||||
|
||||
case 'get-results':
|
||||
case 'get-results': {
|
||||
return {
|
||||
respondentCounts: cache.counters,
|
||||
results: cache.buildAggregatedResults(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'get-live-results':
|
||||
case 'get-live-results': {
|
||||
return {
|
||||
respondentCounts: cache.counters,
|
||||
results: cache.buildAggregatedResults(),
|
||||
liveCounts: getPresenceCounts(ctx).data,
|
||||
};
|
||||
}
|
||||
|
||||
case 'get-public-survey': {
|
||||
const survey = cache.survey;
|
||||
if (survey.status !== 'published') throw new ServiceError('Survey not found', 404);
|
||||
if (survey.status !== 'published') {throw new ServiceError('Survey not found', 404);}
|
||||
const { password_hash: _, ...safeSurvey } = survey;
|
||||
return { survey: safeSurvey, sections: cache.sections, questions: cache.questions };
|
||||
}
|
||||
@@ -174,7 +176,7 @@ async function handleOp(
|
||||
// A cached respondent with hasSubmitted=false provably has zero answers, so we
|
||||
// can skip SQL; anything else must read SQL (only choice answers are cached).
|
||||
const respondentId = getWsRespondentId(ws, ctx);
|
||||
if (!respondentId) throw new ServiceError('Not a respondent connection', 401);
|
||||
if (!respondentId) {throw new ServiceError('Not a respondent connection', 401);}
|
||||
|
||||
const cachedState = cache.getCachedRespondent(respondentId);
|
||||
if (cachedState && !cachedState.hasSubmitted) {
|
||||
@@ -189,7 +191,7 @@ async function handleOp(
|
||||
for (const row of answerRows) {
|
||||
answers[row.question_id] = {
|
||||
value: row.answer,
|
||||
...(row.other_text ? { otherText: row.other_text } : {}),
|
||||
...(row.other_text && { otherText: row.other_text }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,7 +209,7 @@ async function handleOp(
|
||||
const questions = cache.questions;
|
||||
let lastAnsweredIndex = -1;
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
if (questions[i].id in answers) lastAnsweredIndex = i;
|
||||
if (questions[i].id in answers) {lastAnsweredIndex = i;}
|
||||
}
|
||||
const nextQuestionIndex = Math.max(0, lastAnsweredIndex);
|
||||
|
||||
@@ -216,7 +218,7 @@ async function handleOp(
|
||||
|
||||
case 'submit-answers': {
|
||||
const respondentId = getWsRespondentId(ws, ctx);
|
||||
if (!respondentId) throw new ServiceError('Not a respondent connection', 401);
|
||||
if (!respondentId) {throw new ServiceError('Not a respondent connection', 401);}
|
||||
|
||||
const survey = cache.survey;
|
||||
if (survey.closes_at && new Date(survey.closes_at) < new Date()) {
|
||||
@@ -232,10 +234,10 @@ async function handleOp(
|
||||
const row = ctx.storage.sql
|
||||
.exec('SELECT is_complete FROM respondents WHERE id = ? LIMIT 1', respondentId)
|
||||
.toArray()[0] as { is_complete: number } | undefined;
|
||||
if (!row) throw new ServiceError('Respondent not found', 404);
|
||||
if (!row) {throw new ServiceError('Respondent not found', 404);}
|
||||
isComplete = row.is_complete === 1;
|
||||
}
|
||||
if (isComplete) throw new ServiceError('Survey already completed', 409);
|
||||
if (isComplete) {throw new ServiceError('Survey already completed', 409);}
|
||||
|
||||
const answers = (
|
||||
data as {
|
||||
@@ -249,7 +251,7 @@ async function handleOp(
|
||||
const questionMap = new Map(cache.questions.map((cq) => [cq.id, cq]));
|
||||
for (const a of answers) {
|
||||
const cq = questionMap.get(a.questionId);
|
||||
if (!cq) throw new ServiceError(`Invalid question ID: ${a.questionId}`, 400);
|
||||
if (!cq) {throw new ServiceError(`Invalid question ID: ${a.questionId}`, 400);}
|
||||
const spec: QuestionSpec = {
|
||||
type: cq.type,
|
||||
required: cq.required === 1,
|
||||
@@ -259,7 +261,7 @@ async function handleOp(
|
||||
config: cq.config ? JSON.parse(cq.config) : undefined,
|
||||
};
|
||||
const error = validateAnswer(spec, a.value, a.otherText);
|
||||
if (error) throw new ServiceError(error, 400);
|
||||
if (error) {throw new ServiceError(error, 400);}
|
||||
}
|
||||
|
||||
// answer_ms is clamped via the shared helper so it stays in lockstep with the
|
||||
@@ -289,7 +291,7 @@ async function handleOp(
|
||||
// bump counters or rewrite completed_at — hence the 0→1 gated UPDATE and the
|
||||
// `transitioned` check around every cache mutation.
|
||||
const respondentId = getWsRespondentId(ws, ctx);
|
||||
if (!respondentId) throw new ServiceError('Not a respondent connection', 401);
|
||||
if (!respondentId) {throw new ServiceError('Not a respondent connection', 401);}
|
||||
|
||||
const cursor = ctx.storage.sql.exec(
|
||||
'UPDATE respondents SET is_complete = 1, completed_at = ? WHERE id = ? AND is_complete = 0',
|
||||
@@ -308,9 +310,9 @@ async function handleOp(
|
||||
}
|
||||
|
||||
default: {
|
||||
const opStr = String(op);
|
||||
const opStr = op;
|
||||
const result = await execute(opStr, data, cmdCtx);
|
||||
if (result === null) throw new ServiceError(`Unknown operation: ${opStr}`, 400);
|
||||
if (result === null) {throw new ServiceError(`Unknown operation: ${opStr}`, 400);}
|
||||
return result ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ function registerAllRoutes(router: ReturnType<typeof AutoRouter>) {
|
||||
// checks in itty-router. This custom format handler detects Response-like objects by
|
||||
// checking for the `status` and `headers` properties instead of using instanceof.
|
||||
const nodeFormat = (value: unknown) => {
|
||||
if (value === undefined || value === null) return value;
|
||||
if (typeof value === 'object' && 'status' in (value as object) && 'headers' in (value as object)) return value;
|
||||
return new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json; charset=utf-8' } });
|
||||
if (value === undefined || value === null) {return value;}
|
||||
if (typeof value === 'object' && 'status' in (value as object) && 'headers' in (value as object)) {return value;}
|
||||
return Response.json(value, { headers: { 'content-type': 'application/json; charset=utf-8' } });
|
||||
};
|
||||
|
||||
export function createRouter(ctx: AppContext) {
|
||||
@@ -118,7 +118,7 @@ const slugCache = new Map<string, CachedSlugEntry>();
|
||||
const INTERNAL_HEADERS = ['X-WS-Role', 'X-Respondent-Id', 'X-Authenticated', 'X-Survey-Pw-Fp'];
|
||||
|
||||
function stripInternalHeaders(headers: Headers): void {
|
||||
for (const h of INTERNAL_HEADERS) headers.delete(h);
|
||||
for (const h of INTERNAL_HEADERS) {headers.delete(h);}
|
||||
}
|
||||
|
||||
async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } | null> {
|
||||
@@ -133,7 +133,7 @@ async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } |
|
||||
slugCache.set(slug, { id: row.id, cachedAt: Date.now() });
|
||||
if (slugCache.size > MAX_SLUG_CACHE_ENTRIES) {
|
||||
const oldestSlug = slugCache.keys().next().value;
|
||||
if (oldestSlug) slugCache.delete(oldestSlug);
|
||||
if (oldestSlug) {slugCache.delete(oldestSlug);}
|
||||
}
|
||||
}
|
||||
return row;
|
||||
@@ -145,8 +145,8 @@ async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } |
|
||||
* the TTL to expire.
|
||||
*/
|
||||
function invalidateSlugCacheBySurveyId(surveyId: string): void {
|
||||
for (const [slug, entry] of slugCache.entries()) {
|
||||
if (entry.id === surveyId) slugCache.delete(slug);
|
||||
for (const [slug, entry] of slugCache) {
|
||||
if (entry.id === surveyId) {slugCache.delete(slug);}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,11 +175,11 @@ const CATALOG_SYNC_COLUMNS = new Set([
|
||||
|
||||
async function syncCatalog(response: Response, db: D1Database, surveyId: string): Promise<void> {
|
||||
const syncHeader = response.headers.get('X-Catalog-Sync');
|
||||
if (!syncHeader) return;
|
||||
if (!syncHeader) {return;}
|
||||
try {
|
||||
const fields = JSON.parse(syncHeader) as Record<string, unknown>;
|
||||
const keys = Object.keys(fields).filter((k) => CATALOG_SYNC_COLUMNS.has(k));
|
||||
if (keys.length === 0) return;
|
||||
if (keys.length === 0) {return;}
|
||||
const setClauses = keys.map((k) => `${k} = ?`).join(', ');
|
||||
const values = [...keys.map((k) => fields[k]), surveyId];
|
||||
await db
|
||||
@@ -227,11 +227,11 @@ function matchDORoute(method: string, pathname: string): { surveyId?: string; sl
|
||||
const surveyMatch = pathname.match(SURVEY_ID_PATTERN);
|
||||
if (surveyMatch) {
|
||||
const id = surveyMatch[1];
|
||||
if (id === 'import') return null; // POST /api/surveys/import
|
||||
if (id === 'import') {return null;} // POST /api/surveys/import
|
||||
// Tags live in D1, not in the DO — let itty-router handle them
|
||||
const subPath = surveyMatch[2] || '';
|
||||
if (subPath === '/tags') return null;
|
||||
if (subPath === '/init') return null;
|
||||
if (subPath === '/tags') {return null;}
|
||||
if (subPath === '/init') {return null;}
|
||||
return { surveyId: id };
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ function matchDORoute(method: string, pathname: string): { surveyId?: string; sl
|
||||
if (publicMatch) {
|
||||
const slug = publicMatch[1];
|
||||
const subPath = publicMatch[2] || '';
|
||||
if (subPath === '/auth' || subPath === '/reset') return null;
|
||||
if (subPath === '/auth' || subPath === '/reset') {return null;}
|
||||
return { slug };
|
||||
}
|
||||
|
||||
@@ -271,8 +271,8 @@ export default {
|
||||
if (surveyData?.id) {
|
||||
try {
|
||||
await initDO(env, surveyData.id, body.survey ? body : { survey: surveyData });
|
||||
} catch (e) {
|
||||
console.error('Rolling back survey creation after DO init failure:', e);
|
||||
} catch (error) {
|
||||
console.error('Rolling back survey creation after DO init failure:', error);
|
||||
await env.DB.prepare('DELETE FROM surveys WHERE id = ?').bind(surveyData.id).run();
|
||||
return Response.json({ error: 'Failed to initialize survey storage. Please try again.' }, { status: 503 });
|
||||
}
|
||||
@@ -296,7 +296,7 @@ export default {
|
||||
surveyId = doMatch.surveyId;
|
||||
} else if (doMatch.slug) {
|
||||
const row = await slugToRow(env.DB, doMatch.slug);
|
||||
if (!row) return Response.json({ error: 'Survey not found' }, { status: 404 });
|
||||
if (!row) {return Response.json({ error: 'Survey not found' }, { status: 404 });}
|
||||
surveyId = row.id;
|
||||
} else {
|
||||
return router.fetch(request, env, ctx);
|
||||
@@ -312,7 +312,7 @@ export default {
|
||||
// (always-current) survey.password_hash and rejects with 403 when needed.
|
||||
if (!isPublicRoute) {
|
||||
const authResult = await authenticateRequest(request, config);
|
||||
if (authResult instanceof Response) return authResult;
|
||||
if (authResult instanceof Response) {return authResult;}
|
||||
|
||||
const subPath = pathname.match(SURVEY_ID_PATTERN)?.[2] || '/';
|
||||
const requiredRole = getRequiredDORole(method, subPath);
|
||||
@@ -331,7 +331,7 @@ export default {
|
||||
let wsRole = 'public';
|
||||
if (wsType === 'viewer' || wsType === 'editor') {
|
||||
const authResult = await authenticateRequest(request, config);
|
||||
if (authResult instanceof Response) return authResult;
|
||||
if (authResult instanceof Response) {return authResult;}
|
||||
const minRole = wsType === 'editor' ? 'editor' : 'viewer';
|
||||
if (!checkRole(authResult, minRole)) {
|
||||
return Response.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
@@ -347,7 +347,7 @@ export default {
|
||||
// Set from the verified rid cookie only. Lets the DO tag the connection
|
||||
// so respondent messages don't each need re-authenticating.
|
||||
const respondentId = getRespondentId(request, doMatch.slug);
|
||||
if (respondentId) wsHeaders.set('X-Respondent-Id', respondentId);
|
||||
if (respondentId) {wsHeaders.set('X-Respondent-Id', respondentId);}
|
||||
|
||||
// Verified unconditionally — the DO decides whether a password is
|
||||
// actually required, from its own current password_hash.
|
||||
@@ -356,7 +356,7 @@ export default {
|
||||
? await verifySurveyPasswordTokenSignature(token, surveyId, config.passwordSecret)
|
||||
: { valid: false as const };
|
||||
wsHeaders.set('X-Authenticated', pw.valid ? 'true' : 'false');
|
||||
if (pw.valid && pw.fingerprint) wsHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);
|
||||
if (pw.valid && pw.fingerprint) {wsHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);}
|
||||
}
|
||||
|
||||
return stub.fetch(new Request(request.url, { method: request.method, headers: wsHeaders }));
|
||||
@@ -393,14 +393,14 @@ export default {
|
||||
// Set from the verified rid cookie only, never from a client header.
|
||||
const slug = doMatch.slug!;
|
||||
const respondentId = getRespondentId(request, slug);
|
||||
if (respondentId) doHeaders.set('X-Respondent-Id', respondentId);
|
||||
if (respondentId) {doHeaders.set('X-Respondent-Id', respondentId);}
|
||||
|
||||
const token = getCookie(request, `spw_${slug}`);
|
||||
const pw = token
|
||||
? await verifySurveyPasswordTokenSignature(token, surveyId, config.passwordSecret)
|
||||
: { valid: false as const };
|
||||
doHeaders.set('X-Authenticated', pw.valid ? 'true' : 'false');
|
||||
if (pw.valid && pw.fingerprint) doHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);
|
||||
if (pw.valid && pw.fingerprint) {doHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);}
|
||||
}
|
||||
|
||||
const doRequest = new Request(request.url, {
|
||||
@@ -503,15 +503,15 @@ function checkRole(role: UserRole, minRole: UserRole): boolean {
|
||||
|
||||
function allowedCredentialedOrigin(request: Request, config: import('./config').AppConfig): string | null {
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) return null;
|
||||
if (!origin) {return null;}
|
||||
try {
|
||||
if (origin === new URL(request.url).origin) return origin;
|
||||
if (origin === new URL(request.url).origin) {return origin;}
|
||||
} catch {
|
||||
// Unparseable request URL — fall through to the configured origin check.
|
||||
}
|
||||
if (config.oidc.redirectUri) {
|
||||
try {
|
||||
if (origin === new URL(config.oidc.redirectUri).origin) return origin;
|
||||
if (origin === new URL(config.oidc.redirectUri).origin) {return origin;}
|
||||
} catch {
|
||||
// Malformed redirect URI — treat as no configured origin.
|
||||
}
|
||||
@@ -526,8 +526,8 @@ function getRequiredDORole(method: string, subPath: string): UserRole {
|
||||
}
|
||||
return 'editor';
|
||||
}
|
||||
if (method !== 'GET' && method !== 'HEAD') return 'editor';
|
||||
if (subPath === '/definition') return 'editor';
|
||||
if (method !== 'GET' && method !== 'HEAD') {return 'editor';}
|
||||
if (subPath === '/definition') {return 'editor';}
|
||||
return 'viewer';
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function authMiddleware(ctx: AppContext): (request: AuthenticatedRequest)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!path.startsWith('/api/')) return;
|
||||
if (!path.startsWith('/api/')) {return;}
|
||||
|
||||
const token = getCookie(request, SESSION_COOKIE_NAME);
|
||||
if (!token) {
|
||||
@@ -36,7 +36,7 @@ export function authMiddleware(ctx: AppContext): (request: AuthenticatedRequest)
|
||||
}
|
||||
|
||||
export function requireRole(user: UserInfo | undefined, minRole: UserRole): void {
|
||||
if (!user) throw new ServiceError('Authentication required', 401);
|
||||
if (!user) {throw new ServiceError('Authentication required', 401);}
|
||||
|
||||
if ((ROLE_HIERARCHY[user.role] ?? 0) < (ROLE_HIERARCHY[minRole] ?? 0)) {
|
||||
throw new ServiceError('Insufficient permissions', 403);
|
||||
|
||||
@@ -37,20 +37,20 @@ function splitSqlStatements(script: string): string[] {
|
||||
|
||||
if (ch === '-' && next === '-') {
|
||||
// Stop at the newline, don't consume it — line numbers must stay right.
|
||||
while (i < script.length && script[i] !== '\n') i++;
|
||||
while (i < script.length && script[i] !== '\n') {i++;}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '/' && next === '*') {
|
||||
i += 2;
|
||||
while (i < script.length && !(script[i] === '*' && script[i + 1] === '/')) i++;
|
||||
while (i < script.length && !(script[i] === '*' && script[i + 1] === '/')) {i++;}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ';') {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) statements.push(trimmed);
|
||||
if (trimmed) {statements.push(trimmed);}
|
||||
current = '';
|
||||
i++;
|
||||
continue;
|
||||
@@ -61,7 +61,7 @@ function splitSqlStatements(script: string): string[] {
|
||||
}
|
||||
|
||||
const trailing = current.trim();
|
||||
if (trailing) statements.push(trailing);
|
||||
if (trailing) {statements.push(trailing);}
|
||||
return statements;
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,8 @@ export class RespondentRepository {
|
||||
])
|
||||
.execute();
|
||||
return rows.map((r) => ({
|
||||
question_id: String(r.question_id),
|
||||
question_text: String(r.question_text),
|
||||
question_id: r.question_id,
|
||||
question_text: r.question_text,
|
||||
question_sort: Number(r.question_sort),
|
||||
answer_ms: Number(r.answer_ms),
|
||||
}));
|
||||
@@ -200,7 +200,7 @@ export class AnswerRepository {
|
||||
answer_ms: number | null;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (answers.length === 0) return;
|
||||
if (answers.length === 0) {return;}
|
||||
|
||||
// Hand-written multi-row INSERT ... ON CONFLICT: one round-trip regardless
|
||||
// of batch size, where a per-answer upsert would cost N round-trips.
|
||||
@@ -210,10 +210,12 @@ export class AnswerRepository {
|
||||
sql`(${a.respondent_id}, ${a.question_id}, ${a.answer}, ${a.other_text}, ${a.answered_at}, ${a.answer_ms})`,
|
||||
),
|
||||
);
|
||||
await sql`INSERT INTO answers (respondent_id, question_id, answer, other_text, answered_at, answer_ms)
|
||||
VALUES ${values}
|
||||
ON CONFLICT (respondent_id, question_id)
|
||||
DO UPDATE SET answer = excluded.answer, other_text = excluded.other_text, answered_at = excluded.answered_at, answer_ms = excluded.answer_ms`.execute(
|
||||
await sql`
|
||||
INSERT INTO answers (respondent_id, question_id, answer, other_text, answered_at, answer_ms)
|
||||
VALUES ${values}
|
||||
ON CONFLICT (respondent_id, question_id)
|
||||
DO UPDATE SET answer = excluded.answer, other_text = excluded.other_text, answered_at = excluded.answered_at, answer_ms = excluded.answer_ms
|
||||
`.execute(
|
||||
this.db,
|
||||
);
|
||||
}
|
||||
@@ -322,7 +324,7 @@ export class AnswerRepository {
|
||||
results: Array<{ respondent_id: string; question_id: string; question_text: string; answer: string }>;
|
||||
total: number;
|
||||
}> {
|
||||
const escaped = query.replace(/[%_]/g, (ch) => `\\${ch}`);
|
||||
const escaped = query.replaceAll(/[%_]/g, (ch) => `\\${ch}`);
|
||||
const likeQuery = `%${escaped}%`;
|
||||
|
||||
let baseQb = this.db
|
||||
|
||||
@@ -52,7 +52,7 @@ export class SurveyRepository {
|
||||
}
|
||||
|
||||
async update(id: string, fields: Partial<Omit<import('../db').SurveyRow, 'id' | 'created_at'>>): Promise<void> {
|
||||
if (Object.keys(fields).length === 0) return;
|
||||
if (Object.keys(fields).length === 0) {return;}
|
||||
await this.db.updateTable('surveys').set(fields).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class SectionRepository {
|
||||
}
|
||||
|
||||
async update(id: string, fields: Partial<Omit<import('../db').SectionRow, 'id' | 'survey_id'>>): Promise<void> {
|
||||
if (Object.keys(fields).length === 0) return;
|
||||
if (Object.keys(fields).length === 0) {return;}
|
||||
await this.db.updateTable('survey_sections').set(fields).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export class QuestionRepository {
|
||||
}
|
||||
|
||||
async update(id: string, fields: Partial<Omit<import('../db').QuestionRow, 'id' | 'survey_id'>>): Promise<void> {
|
||||
if (Object.keys(fields).length === 0) return;
|
||||
if (Object.keys(fields).length === 0) {return;}
|
||||
await this.db.updateTable('survey_questions').set(fields).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export class TagRepository {
|
||||
}
|
||||
|
||||
async update(id: string, fields: Partial<Omit<import('../db').TagRow, 'id' | 'created_at'>>): Promise<void> {
|
||||
if (Object.keys(fields).length === 0) return;
|
||||
if (Object.keys(fields).length === 0) {return;}
|
||||
await this.db.updateTable('tags').set(fields).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function registerAuthRoutes(router: AppRouter) {
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { password?: string };
|
||||
if (!body.password) throw new ServiceError('Password is required', 400);
|
||||
if (!body.password) {throw new ServiceError('Password is required', 400);}
|
||||
|
||||
await authService.setupAdmin(body.password);
|
||||
|
||||
@@ -61,7 +61,7 @@ export function registerAuthRoutes(router: AppRouter) {
|
||||
const sessionToken = await authService.createSessionToken(user);
|
||||
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
return Response.json({ success: true }, {
|
||||
status: 201,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -78,12 +78,12 @@ export function registerAuthRoutes(router: AppRouter) {
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { password?: string };
|
||||
if (!body.password) throw new ServiceError('Password is required', 400);
|
||||
if (!body.password) {throw new ServiceError('Password is required', 400);}
|
||||
const user = await authService.passwordLogin(body.password);
|
||||
const sessionToken = await authService.createSessionToken(user);
|
||||
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
|
||||
|
||||
return new Response(JSON.stringify({ success: true, user }), {
|
||||
return Response.json({ success: true, user }, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -135,11 +135,11 @@ export function registerAuthRoutes(router: AppRouter) {
|
||||
const returnedState = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) throw new ServiceError('Authentication failed', 400);
|
||||
if (!code || !returnedState) throw new ServiceError('Missing code or state', 400);
|
||||
if (error) {throw new ServiceError('Authentication failed', 400);}
|
||||
if (!code || !returnedState) {throw new ServiceError('Missing code or state', 400);}
|
||||
|
||||
const stateCookie = getCookie(request, AUTH_STATE_COOKIE_NAME);
|
||||
if (!stateCookie) throw new ServiceError('Missing auth state cookie', 400);
|
||||
if (!stateCookie) {throw new ServiceError('Missing auth state cookie', 400);}
|
||||
|
||||
let stateData: { state: string; nonce: string; returnTo: string };
|
||||
try {
|
||||
@@ -147,7 +147,7 @@ export function registerAuthRoutes(router: AppRouter) {
|
||||
} catch {
|
||||
throw new ServiceError('Invalid auth state', 400);
|
||||
}
|
||||
if (stateData.state !== returnedState) throw new ServiceError('State mismatch', 400);
|
||||
if (stateData.state !== returnedState) {throw new ServiceError('State mismatch', 400);}
|
||||
|
||||
const ctx = getContext(request);
|
||||
const authService = new AuthService(ctx.config, ctx.db);
|
||||
|
||||
@@ -14,7 +14,7 @@ async function validatePasswordSession(
|
||||
survey: SurveyRow,
|
||||
ctx: AppContext,
|
||||
): Promise<void> {
|
||||
if (!survey.password_hash) return;
|
||||
if (!survey.password_hash) {return;}
|
||||
|
||||
const token = getCookie(request, `spw_${slug}`);
|
||||
if (!token || !(await verifySurveyPasswordToken(token, survey.id, survey.password_hash, ctx.config.passwordSecret))) {
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
* strips the same prefixes — one module so the two can't drift.
|
||||
*/
|
||||
|
||||
/** /api/surveys/:surveyId(/...)? */
|
||||
/**
|
||||
/api/surveys/:surveyId(/...)?
|
||||
*/
|
||||
export const SURVEY_ID_PATTERN = /^\/api\/surveys\/([^/]+)(\/.*)?$/;
|
||||
|
||||
/** /api/s/:slug(/...)? */
|
||||
/**
|
||||
/api/s/:slug(/...)?
|
||||
*/
|
||||
export const PUBLIC_PATTERN = /^\/api\/s\/([^/]+)(\/.*)?$/;
|
||||
|
||||
@@ -45,8 +45,8 @@ async function main() {
|
||||
const migrationsDir = join(__dirname, '..', 'migrations');
|
||||
try {
|
||||
await runMigrations(db, migrationsDir);
|
||||
} catch (e) {
|
||||
console.error('Migration error:', e);
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
}
|
||||
|
||||
const ctx: AppContext = { db, config };
|
||||
@@ -113,7 +113,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Failed to start server:', e);
|
||||
main().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ export class AuthService {
|
||||
return cachedOidcConfig.data;
|
||||
}
|
||||
const res = await fetch(`${this.config.oidc.issuer}/.well-known/openid-configuration`);
|
||||
if (!res.ok) throw new ServiceError('Failed to fetch OIDC configuration', 500);
|
||||
if (!res.ok) {throw new ServiceError('Failed to fetch OIDC configuration', 500);}
|
||||
const data = (await res.json()) as OidcConfig;
|
||||
cachedOidcConfig = { data, fetchedAt: Date.now() };
|
||||
return data;
|
||||
@@ -100,7 +100,7 @@ export class AuthService {
|
||||
}
|
||||
const config = await this.getOidcConfig();
|
||||
const res = await fetch(config.jwks_uri);
|
||||
if (!res.ok) throw new ServiceError('Failed to fetch JWKS', 500);
|
||||
if (!res.ok) {throw new ServiceError('Failed to fetch JWKS', 500);}
|
||||
const data = (await res.json()) as { keys: Array<JsonWebKey & { kid?: string; alg?: string }> };
|
||||
cachedJwks = { data, fetchedAt: Date.now() };
|
||||
return data;
|
||||
@@ -141,11 +141,11 @@ export class AuthService {
|
||||
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const config = await this.getOidcConfig();
|
||||
if (!config.userinfo_endpoint) return null;
|
||||
if (!config.userinfo_endpoint) {return null;}
|
||||
const res = await fetch(config.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {return null;}
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -154,9 +154,9 @@ export class AuthService {
|
||||
|
||||
async validateIdToken(idToken: string, nonce: string, accessToken?: string): Promise<UserInfo> {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) throw new ServiceError('Invalid ID token format', 400);
|
||||
if (parts.length !== 3) {throw new ServiceError('Invalid ID token format', 400);}
|
||||
|
||||
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/'))) as {
|
||||
const header = JSON.parse(atob(parts[0].replaceAll('-', '+').replaceAll('_', '/'))) as {
|
||||
kid?: string;
|
||||
alg?: string;
|
||||
};
|
||||
@@ -165,16 +165,16 @@ export class AuthService {
|
||||
throw new ServiceError(`Unsupported token algorithm: ${header.alg}`, 400);
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>;
|
||||
const payload = JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))) as Record<string, unknown>;
|
||||
|
||||
if (payload.iss !== this.config.oidc.issuer) throw new ServiceError('Invalid issuer', 400);
|
||||
if (payload.iss !== this.config.oidc.issuer) {throw new ServiceError('Invalid issuer', 400);}
|
||||
if (
|
||||
payload.aud !== this.config.oidc.clientId &&
|
||||
!(Array.isArray(payload.aud) && (payload.aud as string[]).includes(this.config.oidc.clientId))
|
||||
) {
|
||||
throw new ServiceError('Invalid audience', 400);
|
||||
}
|
||||
if (payload.nonce !== nonce) throw new ServiceError('Invalid nonce', 400);
|
||||
if (payload.nonce !== nonce) {throw new ServiceError('Invalid nonce', 400);}
|
||||
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) {
|
||||
throw new ServiceError('Token expired', 400);
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export class AuthService {
|
||||
jwks = await this.getJwks();
|
||||
key = jwks.keys.find((k) => k.kid === header.kid);
|
||||
}
|
||||
if (!key) throw new ServiceError('No matching signing key found', 400);
|
||||
if (!key) {throw new ServiceError('No matching signing key found', 400);}
|
||||
|
||||
const signingKey = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
@@ -201,11 +201,11 @@ export class AuthService {
|
||||
const signatureValid = await crypto.subtle.verify(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
signingKey,
|
||||
Uint8Array.from(atob(parts[2].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
|
||||
Uint8Array.from(atob(parts[2].replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)),
|
||||
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
|
||||
);
|
||||
|
||||
if (!signatureValid) throw new ServiceError('Invalid token signature', 400);
|
||||
if (!signatureValid) {throw new ServiceError('Invalid token signature', 400);}
|
||||
|
||||
let claims: Record<string, unknown> = payload;
|
||||
if (accessToken) {
|
||||
@@ -239,14 +239,14 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.includes(this.config.oidc.roleMapAdmin)) return 'admin';
|
||||
if (value.includes(this.config.oidc.roleMapEditor)) return 'editor';
|
||||
if (value.includes(this.config.oidc.roleMapAdmin)) {return 'admin';}
|
||||
if (value.includes(this.config.oidc.roleMapEditor)) {return 'editor';}
|
||||
return 'viewer';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value === this.config.oidc.roleMapAdmin) return 'admin';
|
||||
if (value === this.config.oidc.roleMapEditor) return 'editor';
|
||||
if (value === this.config.oidc.roleMapAdmin) {return 'admin';}
|
||||
if (value === this.config.oidc.roleMapEditor) {return 'editor';}
|
||||
}
|
||||
|
||||
return 'viewer';
|
||||
@@ -277,8 +277,8 @@ export class AuthService {
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${header}.${payload}`));
|
||||
const signature = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return `${header}.${payload}.${signature}`;
|
||||
|
||||
@@ -68,7 +68,7 @@ function validateRow(table: string, row: unknown, index: number): Record<string,
|
||||
throw new Error(`Backup ${table}[${index}]: row must be an object`);
|
||||
}
|
||||
const record = row as Record<string, unknown>;
|
||||
const allowed = new Set(TABLE_COLUMNS[table] ?? []);
|
||||
const allowed = new Set(TABLE_COLUMNS[table]);
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowed.has(key)) {
|
||||
throw new Error(`Backup ${table}[${index}]: unknown column '${key}'`);
|
||||
@@ -140,7 +140,7 @@ export class BackupService {
|
||||
|
||||
for (const table of REQUIRED_TABLES) {
|
||||
if (!Array.isArray(backup.data[table])) {
|
||||
throw new Error(`Backup is missing required table: ${table}`);
|
||||
throw new TypeError(`Backup is missing required table: ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ function countsMessage(room: SurveyRoom): string {
|
||||
}
|
||||
|
||||
function scheduleBroadcast(room: SurveyRoom) {
|
||||
if (room.broadcastTimer) return;
|
||||
if (room.broadcastTimer) {return;}
|
||||
room.broadcastTimer = setTimeout(() => {
|
||||
room.broadcastTimer = undefined;
|
||||
const msg = countsMessage(room);
|
||||
@@ -46,10 +46,12 @@ function scheduleBroadcast(room: SurveyRoom) {
|
||||
}
|
||||
|
||||
function cleanupRoom(slug: string, room: SurveyRoom) {
|
||||
if (room.viewers.size === 0 && room.respondents.size === 0) {
|
||||
clearTimeout(room.broadcastTimer);
|
||||
rooms.delete(slug);
|
||||
if (!(room.viewers.size === 0 && room.respondents.size === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(room.broadcastTimer);
|
||||
rooms.delete(slug);
|
||||
}
|
||||
|
||||
export function handlePresenceUpgrade(ws: WebSocket, slug: string, type: 'viewer' | 'respondent'): void {
|
||||
|
||||
@@ -44,11 +44,13 @@ export class RespondentService {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (!survey.max_responses) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export class RespondentService {
|
||||
for (const row of answerRows) {
|
||||
answersMap[row.question_id] = {
|
||||
value: row.answer,
|
||||
...(row.other_text ? { otherText: row.other_text } : {}),
|
||||
...(row.other_text && { otherText: row.other_text }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,7 +140,7 @@ export class RespondentService {
|
||||
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);
|
||||
if (!sq) {throw new ServiceError(`Invalid question ID: ${input.questionId}`, 400);}
|
||||
const spec: QuestionSpec = {
|
||||
type: sq.type,
|
||||
required: sq.required === 1,
|
||||
@@ -148,7 +150,7 @@ export class RespondentService {
|
||||
config: sq.config ? JSON.parse(sq.config) : undefined,
|
||||
};
|
||||
const error = validateAnswer(spec, input.value, input.otherText);
|
||||
if (error) throw new ServiceError(error, 400);
|
||||
if (error) {throw new ServiceError(error, 400);}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -277,7 +279,7 @@ export class RespondentService {
|
||||
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');
|
||||
if (col.hasOther) {headers.push(this.csvSafe(col.text) + '_other');}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
@@ -288,7 +290,7 @@ export class RespondentService {
|
||||
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 ?? '')}"`);
|
||||
if (col.hasOther) {row.push(`"${this.csvSafe(answer?.otherText ?? '')}"`);}
|
||||
}
|
||||
controller.enqueue(encoder.encode(row.join(',') + '\n'));
|
||||
}
|
||||
@@ -304,7 +306,7 @@ export class RespondentService {
|
||||
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);
|
||||
if (!survey) {throw new ServiceError('Survey not found', 404);}
|
||||
return this.respondents.getTimelineData(surveyId, granularity);
|
||||
}
|
||||
|
||||
@@ -324,10 +326,10 @@ export class RespondentService {
|
||||
}>
|
||||
> {
|
||||
const survey = await this.surveys.getById(surveyId);
|
||||
if (!survey) throw new ServiceError('Survey not found', 404);
|
||||
if (!survey) {throw new ServiceError('Survey not found', 404);}
|
||||
|
||||
const rows = await this.respondents.getAnswerDurationsByQuestion(surveyId);
|
||||
if (rows.length === 0) return [];
|
||||
if (rows.length === 0) {return [];}
|
||||
|
||||
const byQ = new Map<string, { questionId: string; questionText: string; sort: number; durations: number[] }>();
|
||||
for (const r of rows) {
|
||||
@@ -372,7 +374,7 @@ export class RespondentService {
|
||||
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);
|
||||
if (!survey) {throw new ServiceError('Survey not found', 404);}
|
||||
const durations = await this.respondents.getCompletionDurationsSeconds(surveyId);
|
||||
const count = durations.length;
|
||||
|
||||
@@ -400,7 +402,7 @@ export class RespondentService {
|
||||
p25: percentile(sorted, 25),
|
||||
p75: percentile(sorted, 75),
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
max: sorted.at(-1),
|
||||
buckets,
|
||||
};
|
||||
}
|
||||
@@ -415,7 +417,7 @@ export class RespondentService {
|
||||
}>
|
||||
> {
|
||||
const survey = await this.surveys.getById(surveyId);
|
||||
if (!survey) throw new ServiceError('Survey not found', 404);
|
||||
if (!survey) {throw new ServiceError('Survey not found', 404);}
|
||||
|
||||
const counts = await this.respondents.countBySurveyId(surveyId);
|
||||
const dropoffData = await this.answers.getDropoffData(surveyId);
|
||||
@@ -447,7 +449,7 @@ export class RespondentService {
|
||||
total: number;
|
||||
}> {
|
||||
const survey = await this.surveys.getById(surveyId);
|
||||
if (!survey) throw new ServiceError('Survey not found', 404);
|
||||
if (!survey) {throw new ServiceError('Survey not found', 404);}
|
||||
|
||||
const data = await this.respondents.listBySurveyId(surveyId, offset, limit);
|
||||
return {
|
||||
@@ -508,7 +510,7 @@ export class RespondentService {
|
||||
limit: number;
|
||||
}> {
|
||||
const survey = await this.surveys.getById(surveyId);
|
||||
if (!survey) throw new ServiceError('Survey not found', 404);
|
||||
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);
|
||||
@@ -560,7 +562,7 @@ export class RespondentService {
|
||||
// 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, '""');
|
||||
return safe.replaceAll('"', '""');
|
||||
}
|
||||
|
||||
private async createNewRespondent(surveyId: string, ipAddress: string): Promise<ResumeResult> {
|
||||
|
||||
@@ -182,7 +182,7 @@ export class SurveyService {
|
||||
async updateSurvey(id: string, input: UpdateSurveyInput, existing?: SurveyRow): Promise<SurveyRow> {
|
||||
if (!existing) {
|
||||
existing = (await this.surveys.getById(id)) ?? undefined;
|
||||
if (!existing) throw new ServiceError('Survey not found', 404);
|
||||
if (!existing) {throw new ServiceError('Survey not found', 404);}
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
@@ -209,16 +209,16 @@ export class SurveyService {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
|
||||
if (input.welcome_title !== undefined) fields.welcome_title = input.welcome_title?.trim() ?? null;
|
||||
if (input.welcome_description !== undefined) fields.welcome_description = input.welcome_description?.trim() ?? null;
|
||||
if (input.thank_you_title !== undefined) fields.thank_you_title = input.thank_you_title?.trim() ?? null;
|
||||
if (input.description !== undefined) {fields.description = input.description?.trim() ?? null;}
|
||||
if (input.welcome_title !== undefined) {fields.welcome_title = input.welcome_title?.trim() ?? null;}
|
||||
if (input.welcome_description !== undefined) {fields.welcome_description = input.welcome_description?.trim() ?? null;}
|
||||
if (input.thank_you_title !== undefined) {fields.thank_you_title = input.thank_you_title?.trim() ?? null;}
|
||||
if (input.thank_you_description !== undefined)
|
||||
fields.thank_you_description = input.thank_you_description?.trim() ?? null;
|
||||
if (input.closes_at !== undefined) fields.closes_at = input.closes_at ?? null;
|
||||
if (input.max_responses !== undefined) fields.max_responses = input.max_responses ?? null;
|
||||
if (input.randomize_questions !== undefined) fields.randomize_questions = input.randomize_questions ? 1 : 0;
|
||||
if (input.randomize_options !== undefined) fields.randomize_options = input.randomize_options ? 1 : 0;
|
||||
{fields.thank_you_description = input.thank_you_description?.trim() ?? null;}
|
||||
if (input.closes_at !== undefined) {fields.closes_at = input.closes_at ?? null;}
|
||||
if (input.max_responses !== undefined) {fields.max_responses = input.max_responses ?? null;}
|
||||
if (input.randomize_questions !== undefined) {fields.randomize_questions = input.randomize_questions ? 1 : 0;}
|
||||
if (input.randomize_options !== undefined) {fields.randomize_options = input.randomize_options ? 1 : 0;}
|
||||
|
||||
if (input.password !== undefined) {
|
||||
if (input.password && input.password.length > 0) {
|
||||
@@ -237,12 +237,12 @@ export class SurveyService {
|
||||
|
||||
async deleteSurvey(id: string): Promise<void> {
|
||||
const existing = await this.surveys.getById(id);
|
||||
if (!existing) throw new ServiceError('Survey not found', 404);
|
||||
if (!existing) {throw new ServiceError('Survey not found', 404);}
|
||||
await this.surveys.delete(id);
|
||||
}
|
||||
|
||||
async publishSurvey(id: string, details?: SurveyWithDetails): Promise<SurveyRow> {
|
||||
if (!details) details = await this.getSurvey(id);
|
||||
if (!details) {details = await this.getSurvey(id);}
|
||||
const { survey, sections, questions } = details;
|
||||
|
||||
if (!survey.slug) {
|
||||
@@ -267,7 +267,7 @@ export class SurveyService {
|
||||
async unpublishSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
|
||||
if (!existing) {
|
||||
existing = (await this.surveys.getById(id)) ?? undefined;
|
||||
if (!existing) throw new ServiceError('Survey not found', 404);
|
||||
if (!existing) {throw new ServiceError('Survey not found', 404);}
|
||||
}
|
||||
|
||||
await this.surveys.update(id, {
|
||||
@@ -298,7 +298,7 @@ export class SurveyService {
|
||||
async updateSection(id: string, input: UpdateSectionInput, existing?: SectionRow): Promise<SectionRow> {
|
||||
if (!existing) {
|
||||
existing = (await this.sections.getById(id)) ?? undefined;
|
||||
if (!existing) throw new ServiceError('Section not found', 404);
|
||||
if (!existing) {throw new ServiceError('Section not found', 404);}
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
@@ -308,9 +308,9 @@ export class SurveyService {
|
||||
}
|
||||
fields.title = input.title.trim();
|
||||
}
|
||||
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
|
||||
if (input.description !== undefined) {fields.description = input.description?.trim() ?? null;}
|
||||
|
||||
if (Object.keys(fields).length === 0) return existing;
|
||||
if (Object.keys(fields).length === 0) {return existing;}
|
||||
|
||||
await this.sections.update(id, fields);
|
||||
return { ...existing, ...fields } as SectionRow;
|
||||
@@ -328,7 +328,7 @@ export class SurveyService {
|
||||
async createQuestion(sectionId: string, input: CreateQuestionInput, section?: SectionRow): Promise<QuestionRow> {
|
||||
if (!section) {
|
||||
section = (await this.sections.getById(sectionId)) ?? undefined;
|
||||
if (!section) throw new ServiceError('Section not found', 404);
|
||||
if (!section) {throw new ServiceError('Section not found', 404);}
|
||||
}
|
||||
|
||||
if (!input.text?.trim()) {
|
||||
@@ -352,7 +352,7 @@ export class SurveyService {
|
||||
description: input.description?.trim() ?? null,
|
||||
type: input.type,
|
||||
options: input.options ? JSON.stringify(input.options) : null,
|
||||
required: input.required !== false ? 1 : 0,
|
||||
required: input.required === false ? 0 : 1,
|
||||
has_other: input.has_other ? 1 : 0,
|
||||
other_prompt: input.other_prompt?.trim() ?? null,
|
||||
max_length: input.max_length ?? null,
|
||||
@@ -369,7 +369,7 @@ export class SurveyService {
|
||||
async updateQuestion(id: string, input: UpdateQuestionInput, existing?: QuestionRow): Promise<QuestionRow> {
|
||||
if (!existing) {
|
||||
existing = (await this.questions.getById(id)) ?? undefined;
|
||||
if (!existing) throw new ServiceError('Question not found', 404);
|
||||
if (!existing) {throw new ServiceError('Question not found', 404);}
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
@@ -396,13 +396,13 @@ export class SurveyService {
|
||||
fields.type = input.type;
|
||||
}
|
||||
|
||||
if (input.options !== undefined) fields.options = input.options ? JSON.stringify(input.options) : null;
|
||||
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
|
||||
if (input.required !== undefined) fields.required = input.required ? 1 : 0;
|
||||
if (input.has_other !== undefined) fields.has_other = input.has_other ? 1 : 0;
|
||||
if (input.other_prompt !== undefined) fields.other_prompt = input.other_prompt?.trim() ?? null;
|
||||
if (input.max_length !== undefined) fields.max_length = input.max_length;
|
||||
if (input.placeholder !== undefined) fields.placeholder = input.placeholder?.trim() ?? null;
|
||||
if (input.options !== undefined) {fields.options = input.options ? JSON.stringify(input.options) : null;}
|
||||
if (input.description !== undefined) {fields.description = input.description?.trim() ?? null;}
|
||||
if (input.required !== undefined) {fields.required = input.required ? 1 : 0;}
|
||||
if (input.has_other !== undefined) {fields.has_other = input.has_other ? 1 : 0;}
|
||||
if (input.other_prompt !== undefined) {fields.other_prompt = input.other_prompt?.trim() ?? null;}
|
||||
if (input.max_length !== undefined) {fields.max_length = input.max_length;}
|
||||
if (input.placeholder !== undefined) {fields.placeholder = input.placeholder?.trim() ?? null;}
|
||||
if (input.conditional !== undefined) {
|
||||
fields.conditional = input.conditional ? JSON.stringify(input.conditional) : null;
|
||||
}
|
||||
@@ -410,7 +410,7 @@ export class SurveyService {
|
||||
fields.config = input.config ? JSON.stringify(input.config) : null;
|
||||
}
|
||||
|
||||
if (Object.keys(fields).length === 0) return existing;
|
||||
if (Object.keys(fields).length === 0) {return existing;}
|
||||
|
||||
await this.questions.update(id, fields);
|
||||
return { ...existing, ...fields } as QuestionRow;
|
||||
@@ -495,7 +495,7 @@ export class SurveyService {
|
||||
async archiveSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
|
||||
if (!existing) {
|
||||
existing = (await this.surveys.getById(id)) ?? undefined;
|
||||
if (!existing) throw new ServiceError('Survey not found', 404);
|
||||
if (!existing) {throw new ServiceError('Survey not found', 404);}
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await this.surveys.update(id, { archived_at: now, updated_at: now });
|
||||
@@ -505,7 +505,7 @@ export class SurveyService {
|
||||
async unarchiveSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
|
||||
if (!existing) {
|
||||
existing = (await this.surveys.getById(id)) ?? undefined;
|
||||
if (!existing) throw new ServiceError('Survey not found', 404);
|
||||
if (!existing) {throw new ServiceError('Survey not found', 404);}
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await this.surveys.update(id, { archived_at: null, updated_at: now });
|
||||
@@ -513,7 +513,7 @@ export class SurveyService {
|
||||
}
|
||||
|
||||
async exportDefinition(id: string, details?: SurveyWithDetails): Promise<SurveyDefinition> {
|
||||
if (!details) details = await this.getSurvey(id);
|
||||
if (!details) {details = await this.getSurvey(id);}
|
||||
const { survey, sections, questions } = details;
|
||||
return {
|
||||
version: 1,
|
||||
|
||||
@@ -9,7 +9,7 @@ export class TagService {
|
||||
}
|
||||
|
||||
async createTag(input: { name: string; color?: string }): Promise<TagRow> {
|
||||
if (!input.name?.trim()) throw new ServiceError('Tag name is required', 400);
|
||||
if (!input.name?.trim()) {throw new ServiceError('Tag name is required', 400);}
|
||||
const tag: TagRow = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name.trim(),
|
||||
@@ -22,20 +22,20 @@ export class TagService {
|
||||
|
||||
async updateTag(id: string, input: { name?: string; color?: string | null }): Promise<TagRow> {
|
||||
const existing = await this.tags.getById(id);
|
||||
if (!existing) throw new ServiceError('Tag not found', 404);
|
||||
if (!existing) {throw new ServiceError('Tag not found', 404);}
|
||||
const fields: Partial<Omit<TagRow, 'id' | 'created_at'>> = {};
|
||||
if (input.name !== undefined) {
|
||||
if (!input.name.trim()) throw new ServiceError('Tag name cannot be empty', 400);
|
||||
if (!input.name.trim()) {throw new ServiceError('Tag name cannot be empty', 400);}
|
||||
fields.name = input.name.trim();
|
||||
}
|
||||
if (input.color !== undefined) fields.color = input.color;
|
||||
if (input.color !== undefined) {fields.color = input.color;}
|
||||
await this.tags.update(id, fields);
|
||||
return { ...existing, ...fields };
|
||||
}
|
||||
|
||||
async deleteTag(id: string): Promise<void> {
|
||||
const existing = await this.tags.getById(id);
|
||||
if (!existing) throw new ServiceError('Tag not found', 404);
|
||||
if (!existing) {throw new ServiceError('Tag not found', 404);}
|
||||
await this.tags.delete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PBKDF2_ITERATIONS } from '../constants';
|
||||
|
||||
export function constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
if (a.length !== b.length) {return false;}
|
||||
let result = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
@@ -10,7 +10,7 @@ export function constantTimeEqual(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
if (!password) throw new Error('Password cannot be empty');
|
||||
if (!password) {throw new Error('Password cannot be empty');}
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
@@ -24,8 +24,8 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [saltB64, hashB64] = stored.split(':');
|
||||
if (!saltB64 || !hashB64) return false;
|
||||
const [saltB64, hashB64] = stored.split(':', 2);
|
||||
if (!saltB64 || !hashB64) {return false;}
|
||||
const salt = Uint8Array.from(atob(saltB64), (c: string) => c.charCodeAt(0));
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
@@ -38,7 +38,7 @@ export async function verifyPassword(password: string, stored: string): Promise<
|
||||
}
|
||||
|
||||
export async function signToken(data: string, secret: string): Promise<string> {
|
||||
if (!secret) throw new Error('Signing secret is not configured');
|
||||
if (!secret) {throw new Error('Signing secret is not configured');}
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
@@ -56,13 +56,13 @@ export async function verifyToken(data: string, token: string, secret: string):
|
||||
}
|
||||
|
||||
export function passwordFingerprint(passwordHash: string | null | undefined): string {
|
||||
if (!passwordHash) return 'none';
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0x01000193;
|
||||
if (!passwordHash) {return 'none';}
|
||||
let h1 = 0x81_1C_9D_C5;
|
||||
let h2 = 0x01_00_01_93;
|
||||
for (let i = 0; i < passwordHash.length; i++) {
|
||||
const c = passwordHash.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
||||
h2 = Math.imul(h2 ^ c, 0x85ebca6b) >>> 0;
|
||||
h1 = Math.imul(h1 ^ c, 0x01_00_01_93) >>> 0;
|
||||
h2 = Math.imul(h2 ^ c, 0x85_EB_CA_6B) >>> 0;
|
||||
}
|
||||
return h1.toString(36) + h2.toString(36);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
import type { UserInfo } from '../services/auth.service';
|
||||
|
||||
function b64urlToBytes(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||
return Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string, sessionSecret: string): Promise<UserInfo | null> {
|
||||
try {
|
||||
if (!sessionSecret) return null;
|
||||
if (!sessionSecret) {return null;}
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
if (parts.length !== 3) {return null;}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -32,12 +32,12 @@ export async function verifySessionToken(token: string, sessionSecret: string):
|
||||
b64urlToBytes(parts[2]),
|
||||
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
|
||||
);
|
||||
if (!valid) return null;
|
||||
if (!valid) {return null;}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>;
|
||||
const payload = JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))) as Record<string, unknown>;
|
||||
|
||||
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) return null;
|
||||
if (typeof payload.sub !== 'string') return null;
|
||||
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) {return null;}
|
||||
if (typeof payload.sub !== 'string') {return null;}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
|
||||
@@ -28,9 +28,9 @@ interface ParsedToken {
|
||||
|
||||
function parse(token: string): ParsedToken | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
if (parts.length !== 3) {return null;}
|
||||
const exp = Number(parts[0]);
|
||||
if (!Number.isInteger(exp) || !parts[1] || !parts[2]) return null;
|
||||
if (!Number.isInteger(exp) || !parts[1] || !parts[2]) {return null;}
|
||||
return { exp, fp: parts[1], sig: parts[2] };
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ export async function verifySurveyPasswordTokenSignature(
|
||||
secret: string,
|
||||
): Promise<{ valid: boolean; fingerprint?: string }> {
|
||||
const parsed = parse(token);
|
||||
if (!parsed) return { valid: false };
|
||||
if (parsed.exp <= nowSeconds()) return { valid: false };
|
||||
if (!parsed) {return { valid: false };}
|
||||
if (parsed.exp <= nowSeconds()) {return { valid: false };}
|
||||
const ok = await verifyToken(signedPayload(surveyId, parsed.exp, parsed.fp), parsed.sig, secret);
|
||||
return ok ? { valid: true, fingerprint: parsed.fp } : { valid: false };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function authedRequest(path: string, options?: RequestInit): Promis
|
||||
}
|
||||
|
||||
export async function getAdminCookie(): Promise<string> {
|
||||
if (adminCookie) return adminCookie;
|
||||
if (adminCookie) {return adminCookie;}
|
||||
|
||||
const meRes = await request('/api/auth/me');
|
||||
const me = (await meRes.json()) as { needsSetup?: boolean };
|
||||
@@ -41,7 +41,7 @@ export async function getAdminCookie(): Promise<string> {
|
||||
});
|
||||
const setCookie = setupRes.headers.get('set-cookie');
|
||||
if (setCookie) {
|
||||
adminCookie = setCookie.split(';')[0];
|
||||
adminCookie = setCookie.split(';', 1)[0];
|
||||
return adminCookie;
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,10 @@ export async function getAdminCookie(): Promise<string> {
|
||||
});
|
||||
const setCookie = loginRes.headers.get('set-cookie');
|
||||
if (setCookie) {
|
||||
adminCookie = setCookie.split(';')[0];
|
||||
adminCookie = setCookie.split(';', 1)[0];
|
||||
}
|
||||
|
||||
if (!adminCookie) throw new Error('Failed to authenticate for integration tests');
|
||||
if (!adminCookie) {throw new Error('Failed to authenticate for integration tests');}
|
||||
return adminCookie;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function createPublishedSurvey(options?: {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
...(options?.password ? { password: options.password } : {}),
|
||||
...(options?.password && { password: options.password }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -148,8 +148,8 @@ export async function createCookieForRole(role: 'admin' | 'editor' | 'viewer'):
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${header}.${payload}`));
|
||||
const signature = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return `${SESSION_COOKIE_NAME}=${header}.${payload}.${signature}`;
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('Respondent Flow', () => {
|
||||
|
||||
it('submits batch answers and completes', async () => {
|
||||
const resumeRes = await request(`/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
|
||||
|
||||
const batchRes = await request(`/api/s/${slug}/answers/batch`, {
|
||||
method: 'POST',
|
||||
@@ -58,7 +58,7 @@ describe('Respondent Flow', () => {
|
||||
|
||||
it('rejects answers for questions not in survey', async () => {
|
||||
const resumeRes = await request(`/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
|
||||
|
||||
const res = await request(`/api/s/${slug}/answers/batch`, {
|
||||
method: 'POST',
|
||||
@@ -105,7 +105,7 @@ describe('Survey Password Protection', () => {
|
||||
body: JSON.stringify({ password: 'survey-pass-1234' }),
|
||||
});
|
||||
expect(authRes.status).toBe(204);
|
||||
const pwCookie = authRes.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
const pwCookie = authRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
|
||||
|
||||
const surveyRes = await request(`/api/s/${slug}`, { cookie: pwCookie });
|
||||
const data = (await surveyRes.json()) as { questions: unknown[] };
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('Results API', () => {
|
||||
questionIds = survey.questionIds;
|
||||
|
||||
const resumeRes = await request(`/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
|
||||
|
||||
await request(`/api/s/${slug}/answers/batch`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Survey creation validation', () => {
|
||||
it('rejects whitespace-only title', async () => {
|
||||
const res = await authedRequest('/api/surveys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: ' ' }),
|
||||
body: JSON.stringify({ title: ' '.repeat(3) }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('Question creation validation', () => {
|
||||
it('rejects whitespace-only question text', async () => {
|
||||
const res = await authedRequest(`/api/surveys/${surveyId}/sections/${sectionId}/questions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: ' ', type: 'text' }),
|
||||
body: JSON.stringify({ text: ' '.repeat(3), type: 'text' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"]
|
||||
"types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"],
|
||||
"rootDir": "../../../"
|
||||
},
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts", "../shared/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.integration.ts'],
|
||||
testTimeout: 15000,
|
||||
hookTimeout: 15000,
|
||||
testTimeout: 15_000,
|
||||
hookTimeout: 15_000,
|
||||
sequence: {
|
||||
concurrent: false,
|
||||
},
|
||||
|
||||
@@ -147,8 +147,8 @@ test.describe.serial('OIDC auth', () => {
|
||||
if (disco.ok) {
|
||||
oidcAvailable = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to start OIDC server:', err);
|
||||
} catch (error) {
|
||||
console.log('Failed to start OIDC server:', error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const TEST_PASSWORD = process.env.TEST_PASSWORD || 'e2e-test-password-12345';
|
||||
let sessionCookie: string | null = null;
|
||||
|
||||
export async function ensureAuth(): Promise<string> {
|
||||
if (sessionCookie) return sessionCookie;
|
||||
if (sessionCookie) {return sessionCookie;}
|
||||
|
||||
const meRes = await fetch(`${API}/api/auth/me`);
|
||||
const me = (await meRes.json()) as { authenticated: boolean; needsSetup?: boolean };
|
||||
@@ -17,7 +17,7 @@ export async function ensureAuth(): Promise<string> {
|
||||
});
|
||||
const cookie = setupRes.headers.get('set-cookie');
|
||||
if (cookie) {
|
||||
sessionCookie = cookie.split(';')[0];
|
||||
sessionCookie = cookie.split(';', 1)[0];
|
||||
return sessionCookie;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function ensureAuth(): Promise<string> {
|
||||
});
|
||||
const cookie = loginRes.headers.get('set-cookie');
|
||||
if (cookie) {
|
||||
sessionCookie = cookie.split(';')[0];
|
||||
sessionCookie = cookie.split(';', 1)[0];
|
||||
}
|
||||
|
||||
if (!sessionCookie) {
|
||||
@@ -52,7 +52,7 @@ export async function apiPost(path: string, body?: unknown, retries = 3) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (res.status === 503 && attempt < retries - 1) {
|
||||
// Retry on 503 (transient DO initialization failure)
|
||||
@@ -63,7 +63,7 @@ export async function apiPost(path: string, body?: unknown, retries = 3) {
|
||||
const text = await res.text();
|
||||
throw new Error(`POST ${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined;
|
||||
if (res.status === 204) {return undefined;}
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,13 @@ export async function apiPut(path: string, body?: unknown) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`PUT ${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined;
|
||||
if (res.status === 204) {return undefined;}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const TEST_USERS: Record<string, TestUser> = {
|
||||
|
||||
const findAccount: FindAccount = async (_ctx, id) => {
|
||||
const entry = Object.entries(TEST_USERS).find(([, u]) => u.claims.sub === id);
|
||||
if (!entry) return undefined;
|
||||
if (!entry) {return undefined;}
|
||||
const [, user] = entry;
|
||||
return {
|
||||
accountId: user.claims.sub,
|
||||
@@ -56,7 +56,7 @@ const findAccount: FindAccount = async (_ctx, id) => {
|
||||
|
||||
function authenticateUser(login: string, password: string) {
|
||||
const user = TEST_USERS[login];
|
||||
if (!user || user.password !== password) return undefined;
|
||||
if (!user || user.password !== password) {return undefined;}
|
||||
return { accountId: user.claims.sub };
|
||||
}
|
||||
|
||||
@@ -146,8 +146,8 @@ export async function startOidcServer(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.method === 'POST' && url.pathname.match(/^\/interaction\/[^/]+\/login$/)) {
|
||||
const uid = url.pathname.split('/')[2];
|
||||
if (ctx.method === 'POST' && /^\/interaction\/[^/]+\/login$/.test(url.pathname)) {
|
||||
const uid = url.pathname.split('/', 3)[2];
|
||||
const body = await readBody(ctx.req);
|
||||
const params = new URLSearchParams(body);
|
||||
const login = params.get('login') ?? '';
|
||||
@@ -177,7 +177,7 @@ export async function startOidcServer(): Promise<{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.method === 'POST' && url.pathname.match(/^\/interaction\/[^/]+\/confirm$/)) {
|
||||
if (ctx.method === 'POST' && /^\/interaction\/[^/]+\/confirm$/.test(url.pathname)) {
|
||||
try {
|
||||
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const {
|
||||
@@ -262,7 +262,7 @@ function readBody(req: import('node:http').IncomingMessage): Promise<string> {
|
||||
}
|
||||
|
||||
// Allow running standalone: `npx tsx e2e/oidc-server.ts`
|
||||
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) {
|
||||
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll('\\', '/'))) {
|
||||
startOidcServer().then(() => console.log('OIDC server started, press Ctrl+C to stop'));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ async function createSimpleSurvey(opts?: {
|
||||
|
||||
await apiPut(`/api/surveys/${survey.id}`, {
|
||||
slug,
|
||||
...(opts?.closesAt ? { closes_at: opts.closesAt } : {}),
|
||||
...(opts?.maxResponses !== undefined ? { max_responses: opts.maxResponses } : {}),
|
||||
...(opts?.closesAt && { closes_at: opts.closesAt }),
|
||||
...((opts?.maxResponses !== undefined) && { max_responses: opts.maxResponses }),
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
@@ -103,7 +103,7 @@ test.describe('Create page with survey templates', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10000 });
|
||||
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10_000 });
|
||||
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const url = page.url();
|
||||
@@ -128,7 +128,7 @@ test.describe('Create page saves sections (blank survey)', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10000 });
|
||||
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10_000 });
|
||||
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.getByText('Add section').click();
|
||||
@@ -412,7 +412,7 @@ test.describe('Bulk option paste', () => {
|
||||
await waitForTransition(page);
|
||||
|
||||
// OptionListEditor renders one input per option.
|
||||
const optionInputs = page.locator('.space-y-1\\.5 input[placeholder="Option label..."]');
|
||||
const optionInputs = page.locator(String.raw`.space-y-1\.5 input[placeholder="Option label..."]`);
|
||||
await expect(optionInputs).toHaveCount(4, { timeout: 3000 });
|
||||
await expect(optionInputs.nth(0)).toHaveValue('Red');
|
||||
await expect(optionInputs.nth(1)).toHaveValue('Green');
|
||||
|
||||
@@ -170,14 +170,14 @@ test.describe('Respondent flow', () => {
|
||||
await page.getByText('Blue', { exact: true }).click();
|
||||
await page.click('button:has-text("Submit")');
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Thank you', { timeout: 10000 });
|
||||
await expect(page.locator('h1')).toContainText('Thank you', { timeout: 10_000 });
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('results show the respondent data', async () => {
|
||||
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
expect(ridCookie).toBeTruthy();
|
||||
|
||||
await fetch(`${API}/api/s/${slug}/answers/batch`, {
|
||||
@@ -203,9 +203,9 @@ test.describe('Respondent flow', () => {
|
||||
|
||||
test('a client-supplied X-Respondent-Id header is ignored (creates a new respondent)', async () => {
|
||||
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
expect(ridCookie).toBeTruthy();
|
||||
const legitId = ridCookie!.split('=')[1];
|
||||
const legitId = ridCookie!.split('=', 2)[1];
|
||||
|
||||
// The API worker must strip client-supplied X-Respondent-Id before forwarding
|
||||
// to the DO, so a forged header must yield a brand-new respondent id.
|
||||
@@ -213,9 +213,9 @@ test.describe('Respondent flow', () => {
|
||||
headers: { 'X-Respondent-Id': legitId },
|
||||
});
|
||||
expect(forgedRes.ok).toBe(true);
|
||||
const forgedCookie = forgedRes.headers.get('set-cookie')?.split(';')[0];
|
||||
const forgedCookie = forgedRes.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
expect(forgedCookie).toBeTruthy();
|
||||
const forgedId = forgedCookie!.split('=')[1];
|
||||
const forgedId = forgedCookie!.split('=', 2)[1];
|
||||
expect(forgedId).not.toBe(legitId);
|
||||
});
|
||||
});
|
||||
@@ -231,13 +231,13 @@ test.describe('WebSocket protocol', () => {
|
||||
|
||||
test('WebSocket connects and receives counts', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const origWS = window.WebSocket;
|
||||
window.WebSocket = class extends origWS {
|
||||
const origWS = WebSocket;
|
||||
globalThis.WebSocket = class extends origWS {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
super(url, protocols);
|
||||
this.addEventListener('message', (e) => {
|
||||
(window as any).__wsMessages = (window as any).__wsMessages || [];
|
||||
(window as any).__wsMessages.push(e.data);
|
||||
(globalThis as any).__wsMessages = (globalThis as any).__wsMessages || [];
|
||||
(globalThis as any).__wsMessages.push(e.data);
|
||||
});
|
||||
}
|
||||
} as any;
|
||||
@@ -247,7 +247,7 @@ test.describe('WebSocket protocol', () => {
|
||||
// Wait for a "counts" push to arrive instead of blindly sleeping 3s.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
((window as any).__wsMessages || [])
|
||||
((globalThis as any).__wsMessages || [])
|
||||
.map((m: string) => {
|
||||
try {
|
||||
return JSON.parse(m);
|
||||
@@ -259,7 +259,7 @@ test.describe('WebSocket protocol', () => {
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
const messages = await page.evaluate(() => (window as any).__wsMessages || []);
|
||||
const messages = await page.evaluate(() => (globalThis as any).__wsMessages || []);
|
||||
const parsed = messages.map((m: string) => JSON.parse(m));
|
||||
const countsMsgs = parsed.filter((m: any) => m.type === 'push' && m.event === 'counts');
|
||||
expect(countsMsgs.length).toBeGreaterThan(0);
|
||||
@@ -293,7 +293,7 @@ test.describe('Editor save flow', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForURL('**/edit/**', { timeout: 10000 });
|
||||
await page.waitForURL('**/edit/**', { timeout: 10_000 });
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Edit Survey');
|
||||
|
||||
@@ -407,7 +407,7 @@ test.describe('Analytics endpoints', () => {
|
||||
surveyId = result.surveyId;
|
||||
|
||||
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
if (ridCookie) {
|
||||
await fetch(`${API}/api/s/${slug}/answers/batch`, {
|
||||
method: 'POST',
|
||||
@@ -520,7 +520,7 @@ test.describe('Respondent reset', () => {
|
||||
|
||||
test('reset clears respondent cookie', async () => {
|
||||
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
|
||||
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
|
||||
expect(ridCookie).toBeTruthy();
|
||||
|
||||
const resetRes = await fetch(`${API}/api/s/${slug}/reset`, {
|
||||
@@ -548,9 +548,9 @@ test.describe('WebSocket typed operations', () => {
|
||||
const ws = new WebSocket(
|
||||
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=editor`,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'request', requestId: 'r1', op: 'get-survey', data: {} }));
|
||||
};
|
||||
});
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'response' && msg.requestId === 'r1') {
|
||||
@@ -559,7 +559,7 @@ test.describe('WebSocket typed operations', () => {
|
||||
}
|
||||
};
|
||||
ws.onerror = () => reject(new Error('WS error'));
|
||||
setTimeout(() => reject(new Error('timeout')), 10000);
|
||||
setTimeout(() => reject(new Error('timeout')), 10_000);
|
||||
});
|
||||
},
|
||||
{ slug },
|
||||
@@ -583,7 +583,7 @@ test.describe('WebSocket typed operations', () => {
|
||||
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=editor`,
|
||||
);
|
||||
const responses: Record<string, unknown> = {};
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'request',
|
||||
@@ -592,7 +592,7 @@ test.describe('WebSocket typed operations', () => {
|
||||
data: { title: 'WS Created Section' },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'response') {
|
||||
@@ -614,7 +614,7 @@ test.describe('WebSocket typed operations', () => {
|
||||
}
|
||||
};
|
||||
ws.onerror = () => reject(new Error('WS error'));
|
||||
setTimeout(() => reject(new Error('timeout')), 10000);
|
||||
setTimeout(() => reject(new Error('timeout')), 10_000);
|
||||
});
|
||||
},
|
||||
{ slug },
|
||||
@@ -632,9 +632,9 @@ test.describe('WebSocket typed operations', () => {
|
||||
const ws = new WebSocket(
|
||||
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=viewer`,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'request', requestId: 'r1', op: 'get-results', data: {} }));
|
||||
};
|
||||
});
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'response' && msg.requestId === 'r1') {
|
||||
@@ -643,7 +643,7 @@ test.describe('WebSocket typed operations', () => {
|
||||
}
|
||||
};
|
||||
ws.onerror = () => reject(new Error('WS error'));
|
||||
setTimeout(() => reject(new Error('timeout')), 10000);
|
||||
setTimeout(() => reject(new Error('timeout')), 10_000);
|
||||
});
|
||||
},
|
||||
{ slug },
|
||||
@@ -665,7 +665,7 @@ test.describe('WebSocket typed operations', () => {
|
||||
);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'push') events.push(msg);
|
||||
if (msg.type === 'push') {events.push(msg);}
|
||||
};
|
||||
ws.onerror = () => reject(new Error('WS error'));
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -250,11 +250,11 @@ test.describe('Full survey with all 10 question types', () => {
|
||||
test('results page renders with stats and question results', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Completed')).toBeVisible();
|
||||
await expect(page.getByText('Completion')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Pick a color' })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'Pick a color' })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'CSV' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'JSON' })).toBeVisible();
|
||||
@@ -287,7 +287,7 @@ test.describe('Survey duplication', () => {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
data = await res.json();
|
||||
if (data.sections?.length > 0) break;
|
||||
if (data.sections?.length > 0) {break;}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
expect(data.sections.length).toBe(1);
|
||||
|
||||
@@ -39,8 +39,8 @@ async function createSurveyWithQuestion(opts: {
|
||||
|
||||
await apiPut(`/api/surveys/${survey.id}`, {
|
||||
slug: opts.slug,
|
||||
...(opts.maxResponses !== undefined ? { max_responses: opts.maxResponses } : {}),
|
||||
...(opts.closesAt ? { closes_at: opts.closesAt } : {}),
|
||||
...((opts.maxResponses !== undefined) && { max_responses: opts.maxResponses }),
|
||||
...(opts.closesAt && { closes_at: opts.closesAt }),
|
||||
});
|
||||
await apiPut(`/api/surveys/${survey.id}/publish`);
|
||||
|
||||
@@ -92,7 +92,7 @@ test.describe('Survey with max_responses limit', () => {
|
||||
await expect(
|
||||
page.locator('text=maximum').or(page.locator('text=closed')).or(page.locator('text=Failed to load survey')),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,7 +121,7 @@ test.describe.serial('Survey with past closes_at date', () => {
|
||||
await page.goto(`/s/${setup.slug}`);
|
||||
|
||||
await expect(page.locator('text=closed').or(page.locator('text=Failed to load survey'))).toBeVisible({
|
||||
timeout: 10000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,12 +109,12 @@ test.describe.serial('Results page features', () => {
|
||||
test('overview tab shows stats cards and question results', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Completed')).toBeVisible();
|
||||
await expect(page.getByText('Completion')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Favorite color' })).toBeVisible({
|
||||
timeout: 10000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByRole('heading', { name: 'Your name' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Rate our service' })).toBeVisible();
|
||||
@@ -125,7 +125,7 @@ test.describe.serial('Results page features', () => {
|
||||
|
||||
test('responses tab shows respondent list', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Responses' }).click();
|
||||
|
||||
@@ -140,7 +140,7 @@ test.describe.serial('Results page features', () => {
|
||||
|
||||
test('search tab finds text answers', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
|
||||
@@ -158,7 +158,7 @@ test.describe.serial('Results page features', () => {
|
||||
|
||||
test('clicking a search result expands the full respondent detail with match highlight', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await page.getByPlaceholder('Search answers...').fill('Alice');
|
||||
await expect(page.getByText('Alice Johnson')).toBeVisible({ timeout: 5000 });
|
||||
@@ -180,7 +180,7 @@ test.describe.serial('Results page features', () => {
|
||||
|
||||
test('completion-time and question-timing charts appear on overview', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Time to Complete' })).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('heading', { name: 'Time per Question' })).toBeVisible();
|
||||
@@ -188,10 +188,10 @@ test.describe.serial('Results page features', () => {
|
||||
|
||||
test('CSV export triggers download', async ({ page }) => {
|
||||
await page.goto(`/results/${setup.surveyId}`);
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Set up download listener before clicking
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'CSV' }).click();
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser,
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Frontend-centric defaults don't apply to the backend/e2e trees; keep
|
||||
// the core TS checks and drop the rest rather than add a second config.
|
||||
files: ['backend/**/*.ts', 'e2e/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/', 'backend/dist/', 'backend/.wrangler/', 'backend/.svelte-kit/'],
|
||||
},
|
||||
];
|
||||
@@ -4,7 +4,7 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '*.e2e.ts',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
expect: { timeout: 5000 },
|
||||
fullyParallel: false,
|
||||
// Retries in CI only, so local runs surface real regressions instead of
|
||||
// masking them.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* otherwise a human-readable error string.
|
||||
*/
|
||||
|
||||
const LIKERT_VALUES = ['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree'];
|
||||
const LIKERT_VALUES = new Set(['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree']);
|
||||
|
||||
/**
|
||||
* Hard cap independent of a question's configured maxLength: without it a
|
||||
@@ -51,7 +51,7 @@ function wordCount(text: string): number {
|
||||
export function validateAnswer(question: QuestionSpec, value: string, otherText?: string): string | null {
|
||||
// The declared type doesn't stop a hand-crafted request sending a JSON
|
||||
// number, which would throw on .trim() and surface as a server 500.
|
||||
if (typeof value !== 'string') value = value == null ? '' : String(value);
|
||||
if (typeof value !== 'string') {value = value == null ? '' : value;}
|
||||
|
||||
// Absolute length ceiling, enforced before any per-type logic so it applies
|
||||
// even to types/configs that would otherwise accept unbounded input.
|
||||
@@ -65,39 +65,49 @@ export function validateAnswer(question: QuestionSpec, value: string, otherText?
|
||||
if (question.required && trimmed === '') {
|
||||
return 'This question is required';
|
||||
}
|
||||
if (trimmed === '') return null;
|
||||
if (trimmed === '') {return null;}
|
||||
|
||||
switch (question.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'textarea': {
|
||||
return validateText(trimmed, question, cfg);
|
||||
}
|
||||
|
||||
case 'email':
|
||||
case 'email': {
|
||||
return validateEmail(trimmed, cfg);
|
||||
}
|
||||
|
||||
case 'number':
|
||||
case 'number': {
|
||||
return validateNumber(trimmed, cfg);
|
||||
}
|
||||
|
||||
case 'rating':
|
||||
case 'rating': {
|
||||
return validateRating(trimmed, cfg);
|
||||
}
|
||||
|
||||
case 'nps':
|
||||
case 'nps': {
|
||||
return validateNps(trimmed);
|
||||
}
|
||||
|
||||
case 'likert':
|
||||
return LIKERT_VALUES.includes(trimmed) ? null : 'Please select a valid option';
|
||||
case 'likert': {
|
||||
return LIKERT_VALUES.has(trimmed) ? null : 'Please select a valid option';
|
||||
}
|
||||
|
||||
case 'radio':
|
||||
case 'radio': {
|
||||
return validateRadio(trimmed, question, otherText);
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
case 'checkbox': {
|
||||
return validateCheckbox(trimmed, question, otherText, cfg);
|
||||
}
|
||||
|
||||
case 'dropdown':
|
||||
case 'dropdown': {
|
||||
return validateDropdown(trimmed, question);
|
||||
}
|
||||
|
||||
default:
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +142,7 @@ function validateEmail(value: string, cfg: NonNullable<QuestionSpec['config']>):
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
if (cfg.allowedDomains && cfg.allowedDomains.length > 0) {
|
||||
const domain = value.split('@')[1]?.toLowerCase();
|
||||
const domain = value.split('@', 2)[1]?.toLowerCase();
|
||||
const allowed = cfg.allowedDomains.map((d) => d.toLowerCase());
|
||||
if (!allowed.includes(domain)) {
|
||||
return `Email must be from: ${cfg.allowedDomains.join(', ')}`;
|
||||
@@ -184,7 +194,7 @@ function validateNps(value: string): string | null {
|
||||
|
||||
function validateRadio(value: string, question: QuestionSpec, otherText?: string): string | null {
|
||||
const validValues = new Set((question.options ?? []).map((o) => o.value));
|
||||
if (question.hasOther) validValues.add('Other');
|
||||
if (question.hasOther) {validValues.add('Other');}
|
||||
if (!validValues.has(value)) {
|
||||
return 'Please select a valid option';
|
||||
}
|
||||
@@ -212,7 +222,7 @@ function validateCheckbox(
|
||||
}
|
||||
|
||||
const validValues = new Set((question.options ?? []).map((o) => o.value));
|
||||
if (question.hasOther) validValues.add('Other');
|
||||
if (question.hasOther) {validValues.add('Other');}
|
||||
for (const v of selected) {
|
||||
if (!validValues.has(v)) {
|
||||
return `Invalid selection: ${v}`;
|
||||
|
||||
@@ -88,7 +88,9 @@ export interface CreateQuestionInput {
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Server rejects a larger submit-answers batch with a 400, so the client chunks its flush to match. */
|
||||
/**
|
||||
Server rejects a larger submit-answers batch with a 400, so the client chunks its flush to match.
|
||||
*/
|
||||
export const BATCH_ANSWER_LIMIT = 20;
|
||||
|
||||
export interface UpdateQuestionInput {
|
||||
@@ -115,7 +117,9 @@ export interface AnswerInput {
|
||||
questionId: string;
|
||||
value: string;
|
||||
otherText?: string;
|
||||
/** Client-measured ms spent on this question before committing. */
|
||||
/**
|
||||
Client-measured ms spent on this question before committing.
|
||||
*/
|
||||
answerMs?: number;
|
||||
}
|
||||
|
||||
@@ -320,7 +324,9 @@ export interface WsPushEvents {
|
||||
analytics: SlowAnalyticsPayload;
|
||||
}
|
||||
|
||||
/** Client → Server */
|
||||
/**
|
||||
Client → Server
|
||||
*/
|
||||
export type WsRequestMessage = {
|
||||
[K in keyof WsOperations]: {
|
||||
type: 'request';
|
||||
@@ -330,7 +336,9 @@ export type WsRequestMessage = {
|
||||
};
|
||||
}[keyof WsOperations];
|
||||
|
||||
/** Server → Client (response to a request) */
|
||||
/**
|
||||
Server → Client (response to a request)
|
||||
*/
|
||||
export type WsResponseMessage =
|
||||
| {
|
||||
[K in keyof WsOperations]: {
|
||||
@@ -347,7 +355,9 @@ export type WsResponseMessage =
|
||||
error: string;
|
||||
};
|
||||
|
||||
/** Server → Client (unsolicited push) */
|
||||
/**
|
||||
Server → Client (unsolicited push)
|
||||
*/
|
||||
export type WsPushMessage = {
|
||||
[K in keyof WsPushEvents]: {
|
||||
type: 'push';
|
||||
|
||||
@@ -12,7 +12,7 @@ function q(overrides: Partial<QuestionSpec> & { type: string }): QuestionSpec {
|
||||
describe('required check', () => {
|
||||
it('rejects empty value on required question', () => {
|
||||
expect(validateAnswer(q({ type: 'text', required: true }), '')).toBe('This question is required');
|
||||
expect(validateAnswer(q({ type: 'text', required: true }), ' ')).toBe('This question is required');
|
||||
expect(validateAnswer(q({ type: 'text', required: true }), ' '.repeat(3))).toBe('This question is required');
|
||||
});
|
||||
|
||||
it('accepts empty value on optional question', () => {
|
||||
@@ -58,8 +58,8 @@ describe('text validation', () => {
|
||||
});
|
||||
|
||||
it('validates against custom pattern', () => {
|
||||
expect(validateAnswer(q({ type: 'text', config: { pattern: '^\\d{3}-\\d{4}$' } }), '123-4567')).toBeNull();
|
||||
expect(validateAnswer(q({ type: 'text', config: { pattern: '^\\d{3}-\\d{4}$' } }), 'abc')).toBe(
|
||||
expect(validateAnswer(q({ type: 'text', config: { pattern: String.raw`^\d{3}-\d{4}$` } }), '123-4567')).toBeNull();
|
||||
expect(validateAnswer(q({ type: 'text', config: { pattern: String.raw`^\d{3}-\d{4}$` } }), 'abc')).toBe(
|
||||
'Answer does not match the required format',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ interface AuthState {
|
||||
export async function getMe(): Promise<AuthState> {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (!res.ok) return { authenticated: false };
|
||||
if (!res.ok) {return { authenticated: false };}
|
||||
return res.json() as Promise<AuthState>;
|
||||
} catch {
|
||||
return { authenticated: false };
|
||||
@@ -29,7 +29,7 @@ export async function setup(password: string, setupToken?: string): Promise<void
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setupToken ? { 'X-Setup-Token': setupToken } : {}),
|
||||
...(setupToken && { 'X-Setup-Token': setupToken }),
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
@@ -54,12 +54,12 @@ export async function passwordLogin(password: string): Promise<void> {
|
||||
}
|
||||
|
||||
export function oidcLogin(returnTo?: string): void {
|
||||
const url = new URL('/api/auth/login', window.location.origin);
|
||||
if (returnTo) url.searchParams.set('returnTo', returnTo);
|
||||
window.location.href = url.toString();
|
||||
const url = new URL('/api/auth/login', location.origin);
|
||||
if (returnTo) {url.searchParams.set('returnTo', returnTo);}
|
||||
location.assign(url.toString());
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/';
|
||||
location.assign('/');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ interface PendingSave {
|
||||
questionId: string;
|
||||
value: string;
|
||||
otherText?: string;
|
||||
/** Client-measured milliseconds spent on this question before committing. */
|
||||
/**
|
||||
Client-measured milliseconds spent on this question before committing.
|
||||
*/
|
||||
answerMs?: number;
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ export function createApiClient(slug: string) {
|
||||
// text field would count and typing "Hello" would flush after 4 characters.
|
||||
const isNew = !answerBuffer.has(data.questionId);
|
||||
answerBuffer.set(data.questionId, data);
|
||||
if (isNew) unflushedCount++;
|
||||
if (isNew) {unflushedCount++;}
|
||||
resetInactivityTimer();
|
||||
|
||||
if (unflushedCount >= FLUSH_THRESHOLD) {
|
||||
@@ -71,7 +73,7 @@ export function createApiClient(slug: string) {
|
||||
while (!wsClient?.connected && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!wsClient?.connected) return false;
|
||||
if (!wsClient?.connected) {return false;}
|
||||
}
|
||||
try {
|
||||
await wsClient.request('submit-answers', { answers });
|
||||
@@ -90,10 +92,9 @@ export function createApiClient(slug: string) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) return true;
|
||||
if (res.status < 500) return false;
|
||||
if (res.ok) {return true;}
|
||||
if (res.status < 500) {return false;}
|
||||
} catch {
|
||||
// network error, retry
|
||||
}
|
||||
@@ -105,7 +106,7 @@ export function createApiClient(slug: string) {
|
||||
}
|
||||
|
||||
async function flushBuffer(): Promise<boolean> {
|
||||
if (answerBuffer.size === 0) return true;
|
||||
if (answerBuffer.size === 0) {return true;}
|
||||
|
||||
if (inactivityTimer !== null) {
|
||||
clearTimeout(inactivityTimer);
|
||||
@@ -121,7 +122,7 @@ export function createApiClient(slug: string) {
|
||||
for (let i = 0; i < all.length; i += BATCH_ANSWER_LIMIT) {
|
||||
const chunk = all.slice(i, i + BATCH_ANSWER_LIMIT);
|
||||
const ok = await saveBatch(chunk);
|
||||
if (!ok) failed.push(...chunk);
|
||||
if (!ok) {failed.push(...chunk);}
|
||||
}
|
||||
const success = failed.length === 0;
|
||||
if (success) {
|
||||
@@ -148,7 +149,7 @@ export function createApiClient(slug: string) {
|
||||
// Page unload path — sendBeacon is HTTP-only. This is the ONE place where
|
||||
// HTTP is used in ws mode, since the WebSocket can't reliably finish
|
||||
// pending sends during unload.
|
||||
if (answerBuffer.size === 0) return;
|
||||
if (answerBuffer.size === 0) {return;}
|
||||
|
||||
if (inactivityTimer !== null) {
|
||||
clearTimeout(inactivityTimer);
|
||||
@@ -194,7 +195,7 @@ export function createApiClient(slug: string) {
|
||||
}
|
||||
|
||||
mode = 'http';
|
||||
const res = await fetch(`${base}/resume`, { credentials: 'same-origin' });
|
||||
const res = await fetch(`${base}/resume`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load survey (${res.status})`);
|
||||
}
|
||||
@@ -211,7 +212,6 @@ export function createApiClient(slug: string) {
|
||||
}
|
||||
const res = await fetch(`${base}/complete`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to submit survey (${res.status})`);
|
||||
@@ -231,10 +231,12 @@ export function createApiClient(slug: string) {
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (inactivityTimer !== null) {
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
if (inactivityTimer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,13 +11,13 @@ export async function request<T>(url: string, options?: RequestInit): Promise<T>
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
// Reload to trigger the layout auth check, which shows the login screen
|
||||
window.location.reload();
|
||||
location.reload();
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
if (res.status === 204) {return undefined as T;}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ export async function listSurveysPaginated(opts: {
|
||||
limit?: number;
|
||||
}): Promise<{ surveys: Survey[]; total: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.includeArchived) params.set('archived', 'true');
|
||||
if (opts.search) params.set('search', opts.search);
|
||||
if (opts.offset) params.set('offset', String(opts.offset));
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
if (opts.includeArchived) {params.set('archived', 'true');}
|
||||
if (opts.search) {params.set('search', opts.search);}
|
||||
if (opts.offset) {params.set('offset', String(opts.offset));}
|
||||
if (opts.limit) {params.set('limit', String(opts.limit));}
|
||||
const data = await request<{ surveys: Array<Record<string, unknown>>; total: number }>(`/api/surveys?${params}`);
|
||||
return { surveys: data.surveys.map(surveyFromApi), total: data.total };
|
||||
}
|
||||
|
||||
@@ -118,9 +118,9 @@ export async function searchAnswers(
|
||||
}),
|
||||
() => {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (questionId) params.set('questionId', questionId);
|
||||
if (pagination?.offset) params.set('offset', String(pagination.offset));
|
||||
if (pagination?.limit) params.set('limit', String(pagination.limit));
|
||||
if (questionId) {params.set('questionId', questionId);}
|
||||
if (pagination?.offset) {params.set('offset', String(pagination.offset));}
|
||||
if (pagination?.limit) {params.set('limit', String(pagination.limit));}
|
||||
return request(`/api/surveys/${id}/results/search?${params}`);
|
||||
},
|
||||
);
|
||||
@@ -164,7 +164,7 @@ export async function getLiveResults(id: string): Promise<LiveResults | null> {
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
window.location.reload();
|
||||
location.reload();
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
@@ -197,7 +197,7 @@ export async function deleteRespondent(surveyId: string, respondentId: string):
|
||||
// Export stays HTTP — binary file download
|
||||
export async function exportResults(id: string, format: 'csv' | 'json'): Promise<void> {
|
||||
const res = await fetch(`/api/surveys/${id}/results/export?format=${format}`);
|
||||
if (!res.ok) throw new Error('Export failed');
|
||||
if (!res.ok) {throw new Error('Export failed');}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -5,7 +5,9 @@ export interface SurveyWsClient {
|
||||
|
||||
on<K extends keyof WsPushEvents>(event: K, callback: (data: WsPushEvents[K]) => void): () => void;
|
||||
|
||||
/** Fires immediately with the current state on subscribe, then on each change. */
|
||||
/**
|
||||
Fires immediately with the current state on subscribe, then on each change.
|
||||
*/
|
||||
onConnectionChange(callback: (state: 'connecting' | 'open' | 'closed' | 'failed') => void): () => void;
|
||||
|
||||
close(): void;
|
||||
@@ -16,8 +18,8 @@ export interface SurveyWsClient {
|
||||
let requestCounter = 0;
|
||||
|
||||
export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent' | 'editor'): SurveyWsClient {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}/api/s/${slug}/ws?type=${type}`;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${location.host}/api/s/${slug}/ws?type=${type}`;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -30,21 +32,21 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
|
||||
const stateListeners = new Set<(s: ConnState) => void>();
|
||||
function setState(next: ConnState) {
|
||||
state = next;
|
||||
for (const cb of stateListeners) cb(next);
|
||||
for (const cb of stateListeners) {cb(next);}
|
||||
}
|
||||
|
||||
const pushListeners = new Map<string, Array<(data: unknown) => void>>();
|
||||
const pendingRequests = new Map<string, { resolve: (data: unknown) => void; reject: (error: Error) => void }>();
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
if (closed) {return;}
|
||||
setState('connecting');
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('open', () => {
|
||||
failures = 0;
|
||||
setState('open');
|
||||
};
|
||||
});
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
@@ -65,7 +67,7 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
|
||||
|
||||
if (msg.type === 'push' && msg.event) {
|
||||
const cbs = pushListeners.get(msg.event);
|
||||
if (cbs) for (const cb of cbs) cb(msg.data);
|
||||
if (cbs) {for (const cb of cbs) {cb(msg.data);}}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -73,13 +75,13 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.addEventListener('close', () => {
|
||||
for (const [, pending] of pendingRequests) {
|
||||
pending.reject(new Error('WebSocket closed'));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
|
||||
if (closed) return;
|
||||
if (closed) {return;}
|
||||
failures++;
|
||||
if (failures < MAX_FAILURES) {
|
||||
setState('closed');
|
||||
@@ -87,7 +89,7 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
|
||||
} else {
|
||||
setState('failed');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
@@ -157,14 +159,14 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
|
||||
|
||||
on<K extends keyof WsPushEvents>(event: K, callback: (data: WsPushEvents[K]) => void): () => void {
|
||||
const key = event as string;
|
||||
if (!pushListeners.has(key)) pushListeners.set(key, []);
|
||||
if (!pushListeners.has(key)) {pushListeners.set(key, []);}
|
||||
const cb = callback as (data: unknown) => void;
|
||||
pushListeners.get(key)!.push(cb);
|
||||
return () => {
|
||||
const list = pushListeners.get(key);
|
||||
if (list) {
|
||||
const idx = list.indexOf(cb);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
if (idx !== -1) {list.splice(idx, 1);}
|
||||
}
|
||||
};
|
||||
},
|
||||
@@ -196,11 +198,13 @@ type WsType = 'viewer' | 'respondent' | 'editor';
|
||||
const connectionsBySlug = new Map<string, SurveyWsClient>(); // key: `${slug}:${type}`
|
||||
const connectionsBySurveyId = new Map<string, SurveyWsClient>();
|
||||
|
||||
/** Get or create a WS client for a survey */
|
||||
/**
|
||||
Get or create a WS client for a survey
|
||||
*/
|
||||
export function getSurveyWs(slug: string, type: WsType): SurveyWsClient {
|
||||
const key = `${slug}:${type}`;
|
||||
const existing = connectionsBySlug.get(key);
|
||||
if (existing?.connected) return existing;
|
||||
if (existing?.connected) {return existing;}
|
||||
existing?.close();
|
||||
|
||||
const client = createSurveyWsClient(slug, type);
|
||||
@@ -208,13 +212,17 @@ export function getSurveyWs(slug: string, type: WsType): SurveyWsClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Get an existing WS client by survey ID (admin flows only) */
|
||||
/**
|
||||
Get an existing WS client by survey ID (admin flows only)
|
||||
*/
|
||||
export function getWsClientById(surveyId: string): SurveyWsClient | undefined {
|
||||
const conn = connectionsBySurveyId.get(surveyId);
|
||||
return conn?.connected ? conn : undefined;
|
||||
}
|
||||
|
||||
/** Get an existing WS client by slug and type */
|
||||
/**
|
||||
Get an existing WS client by slug and type
|
||||
*/
|
||||
export function getWsClientBySlug(slug: string, type: WsType = 'respondent'): SurveyWsClient | undefined {
|
||||
const conn = connectionsBySlug.get(`${slug}:${type}`);
|
||||
return conn?.connected ? conn : undefined;
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
|
||||
async function handlePasswordLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) return;
|
||||
if (!password.trim()) {return;}
|
||||
submitting = true;
|
||||
error = null;
|
||||
try {
|
||||
await passwordLogin(password);
|
||||
await refreshAuth();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Login failed';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Login failed';
|
||||
}
|
||||
submitting = false;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="flex min-h-screen flex-col items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 flex flex-col items-center gap-3 text-center">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-gray-800">
|
||||
<div class="flex size-14 items-center justify-center rounded-full bg-gray-800">
|
||||
<Icon icon={mdiLock} size="28" class="text-gray-400" />
|
||||
</div>
|
||||
<h1 class="text-xl font-bold">Sign in to FUTO Surveys</h1>
|
||||
@@ -73,7 +73,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" onclick={() => oidcLogin(window.location.pathname)}>
|
||||
<Button variant="outline" onclick={() => oidcLogin(location.pathname)}>
|
||||
<Icon icon={mdiOpenInNew} size="16" />
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
try {
|
||||
await setup(password, requiresToken ? setupToken.trim() : undefined);
|
||||
await refreshAuth();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Setup failed';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Setup failed';
|
||||
}
|
||||
submitting = false;
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="flex min-h-screen flex-col items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 flex flex-col items-center gap-3 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-800">
|
||||
<div class="flex size-16 items-center justify-center rounded-full bg-gray-800">
|
||||
<Icon icon={mdiShieldLock} size="32" class="text-immich-primary" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">Welcome to FUTO Surveys</h1>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length < 2) return;
|
||||
if (lines.length < 2) {return;}
|
||||
onSubmit(lines.map((l) => ({ label: l, value: l })));
|
||||
}
|
||||
</script>
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">
|
||||
{lineCount} option{lineCount !== 1 ? 's' : ''}
|
||||
{lineCount} option{lineCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
function removeOption(index: number) {
|
||||
if (options.length <= 2) return;
|
||||
if (options.length <= 2) {return;}
|
||||
onChange(options.filter((_, i) => i !== index));
|
||||
tick().then(() => {
|
||||
const focusIdx = Math.min(index, options.length - 2);
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
function moveOption(index: number, direction: 'up' | 'down') {
|
||||
const target = direction === 'up' ? index - 1 : index + 1;
|
||||
if (target < 0 || target >= options.length) return;
|
||||
if (target < 0 || target >= options.length) {return;}
|
||||
const updated = [...options];
|
||||
[updated[index], updated[target]] = [updated[target], updated[index]];
|
||||
onChange(updated);
|
||||
@@ -99,21 +99,21 @@
|
||||
class="flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<button
|
||||
class="rounded p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
|
||||
class="rounded-sm p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
|
||||
disabled={i === 0}
|
||||
onclick={() => moveOption(i, 'up')}
|
||||
>
|
||||
<Icon icon={mdiArrowUp} size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
|
||||
class="rounded-sm p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
|
||||
disabled={i === options.length - 1}
|
||||
onclick={() => moveOption(i, 'down')}
|
||||
>
|
||||
<Icon icon={mdiArrowDown} size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-gray-500 hover:bg-red-500/10 hover:text-red-400 disabled:pointer-events-none disabled:opacity-0"
|
||||
class="rounded-sm p-1 text-gray-500 hover:bg-red-500/10 hover:text-red-400 disabled:pointer-events-none disabled:opacity-0"
|
||||
disabled={options.length <= 2}
|
||||
onclick={() => removeOption(i)}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
});
|
||||
|
||||
function handleDownload() {
|
||||
if (!dataUrl) return;
|
||||
if (!dataUrl) {return;}
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = 'survey-qr-code.png';
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
if (e.target === e.currentTarget) {onClose();}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -79,10 +79,10 @@
|
||||
type LogicConditionType = 'skipped' | 'equals' | 'notEquals' | 'anyOf';
|
||||
const initialLogicType: LogicConditionType = (() => {
|
||||
const saved = question.config?.skipConditionType;
|
||||
if (saved === 'skipped' || saved === 'equals' || saved === 'notEquals' || saved === 'anyOf') return saved;
|
||||
if (saved === 'skipped' || saved === 'equals' || saved === 'notEquals' || saved === 'anyOf') {return saved;}
|
||||
// Back-compat: infer from which value field is populated if an older
|
||||
// survey was saved before skipConditionType was persisted.
|
||||
if (question.config?.skipConditionValues) return 'anyOf';
|
||||
if (question.config?.skipConditionValues) {return 'anyOf';}
|
||||
return 'equals';
|
||||
})();
|
||||
let logicConditionType = $state<LogicConditionType>(initialLogicType);
|
||||
@@ -105,27 +105,33 @@
|
||||
updated.hasOther = false;
|
||||
}
|
||||
switch (newType) {
|
||||
case 'rating':
|
||||
case 'rating': {
|
||||
updated.config = { scaleMax: 5 };
|
||||
updated.options = [];
|
||||
break;
|
||||
case 'nps':
|
||||
}
|
||||
case 'nps': {
|
||||
updated.config = { scaleMax: 10 };
|
||||
updated.options = [];
|
||||
break;
|
||||
case 'number':
|
||||
}
|
||||
case 'number': {
|
||||
updated.config = { min: 0, max: 100 };
|
||||
updated.options = [];
|
||||
break;
|
||||
case 'likert':
|
||||
}
|
||||
case 'likert': {
|
||||
updated.config = {};
|
||||
updated.options = [];
|
||||
break;
|
||||
case 'dropdown':
|
||||
}
|
||||
case 'dropdown': {
|
||||
// keep options
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
onChange(updated);
|
||||
}
|
||||
@@ -157,7 +163,7 @@
|
||||
</span>
|
||||
{#if question.required}
|
||||
<span
|
||||
class="bg-immich-primary-10 text-immich-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
|
||||
class="bg-immich-primary-10 text-immich-primary shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
|
||||
>
|
||||
Required
|
||||
</span>
|
||||
@@ -171,12 +177,12 @@
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<div class="border-t border-gray-200 px-4 pt-4 pb-4 dark:border-gray-700/60">
|
||||
<div class="border-t border-gray-200 p-4 dark:border-gray-700/60">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500">Question {index + 1}</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
onclick={() => onMove('up')}
|
||||
title="Move up"
|
||||
@@ -184,7 +190,7 @@
|
||||
<Icon icon={mdiArrowUp} size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
disabled={index === total - 1}
|
||||
onclick={() => onMove('down')}
|
||||
title="Move down"
|
||||
@@ -192,7 +198,7 @@
|
||||
<Icon icon={mdiArrowDown} size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
onclick={onDuplicate}
|
||||
title="Duplicate"
|
||||
>
|
||||
@@ -214,7 +220,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
onclick={() => (question.text ? (confirmingDelete = true) : onDelete())}
|
||||
title="Delete"
|
||||
>
|
||||
@@ -625,7 +631,7 @@
|
||||
...question.config,
|
||||
integerOnly: (e.target as HTMLInputElement).checked || undefined,
|
||||
})}
|
||||
class="rounded"
|
||||
class="rounded-sm"
|
||||
/>
|
||||
Whole numbers only
|
||||
</label>
|
||||
|
||||
@@ -70,14 +70,14 @@
|
||||
expandedQuestionIndex = newIndex;
|
||||
tick().then(() => {
|
||||
questionEditorRefs[newIndex]?.focusText();
|
||||
const el = document.querySelector(`[data-question-index="${index}-${newIndex}"]`);
|
||||
const el = document.querySelector(`[data-question-index="${CSS.escape(index)}-${CSS.escape(newIndex)}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
|
||||
function addFromTemplate(templateId: string) {
|
||||
const template = questionTemplates.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
if (!template) {return;}
|
||||
const newQ: BuilderQuestion = {
|
||||
...template.question,
|
||||
id: '',
|
||||
@@ -89,7 +89,7 @@
|
||||
showTemplates = false;
|
||||
tick().then(() => {
|
||||
questionEditorRefs[newIndex]?.focusText();
|
||||
const el = document.querySelector(`[data-question-index="${index}-${newIndex}"]`);
|
||||
const el = document.querySelector(`[data-question-index="${CSS.escape(index)}-${CSS.escape(newIndex)}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
@@ -111,8 +111,8 @@
|
||||
|
||||
function deleteQuestion(qIndex: number) {
|
||||
onChange({ ...section, questions: section.questions.filter((_, i) => i !== qIndex) });
|
||||
if (expandedQuestionIndex === qIndex) expandedQuestionIndex = null;
|
||||
else if (expandedQuestionIndex !== null && expandedQuestionIndex > qIndex) expandedQuestionIndex--;
|
||||
if (expandedQuestionIndex === qIndex) {expandedQuestionIndex = null;}
|
||||
else if (expandedQuestionIndex !== null && expandedQuestionIndex > qIndex) {expandedQuestionIndex--;}
|
||||
}
|
||||
|
||||
function moveQuestion(qIndex: number, direction: 'up' | 'down') {
|
||||
@@ -156,26 +156,26 @@
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-400">Section {index + 1}</span>
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800">
|
||||
{section.questions.length} question{section.questions.length !== 1 ? 's' : ''}
|
||||
{section.questions.length} question{section.questions.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
onclick={() => onMove('up')}
|
||||
>
|
||||
<Icon icon={mdiArrowUp} size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
disabled={index === total - 1}
|
||||
onclick={() => onMove('down')}
|
||||
>
|
||||
<Icon icon={mdiArrowDown} size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
onclick={onDuplicate}
|
||||
title="Duplicate section"
|
||||
>
|
||||
@@ -184,7 +184,7 @@
|
||||
{#if confirmingDelete}
|
||||
<div class="ml-2 flex items-center gap-2 rounded-md bg-red-500/10 px-3 py-1.5">
|
||||
<span class="text-xs text-red-400"
|
||||
>Delete section & {section.questions.length} question{section.questions.length !== 1 ? 's' : ''}?</span
|
||||
>Delete section & {section.questions.length} question{section.questions.length === 1 ? '' : 's'}?</span
|
||||
>
|
||||
<button
|
||||
class="text-xs font-medium text-red-400 hover:text-red-300"
|
||||
@@ -199,7 +199,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
onclick={handleDeleteSection}
|
||||
>
|
||||
<Icon icon={mdiDelete} size="16" />
|
||||
@@ -299,7 +299,7 @@
|
||||
<div
|
||||
class="absolute top-full left-0 z-30 mt-1 w-56 rounded-lg border border-gray-600 bg-gray-900 py-1 shadow-xl"
|
||||
>
|
||||
{#each [...templatesByCategory.entries()] as [category, templates] (category)}
|
||||
{#each [...templatesByCategory] as [category, templates] (category)}
|
||||
<div class="px-3 pt-2 pb-1 text-[10px] font-semibold tracking-wider text-gray-500 uppercase">
|
||||
{category}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const error = $derived(value ? validateSlug(value) : null);
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const raw = (e.target as HTMLInputElement).value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
const raw = (e.target as HTMLInputElement).value.toLowerCase().replaceAll(/[^a-z0-9-]/g, '');
|
||||
onChange(raw);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
let localThankYouTitle = $state(survey.thankYouTitle ?? '');
|
||||
let localThankYouDescription = $state(survey.thankYouDescription ?? '');
|
||||
let localClosesAt = $state(survey.closesAt ?? '');
|
||||
let localMaxResponses = $state(survey.maxResponses != null ? String(survey.maxResponses) : '');
|
||||
let localMaxResponses = $state(survey.maxResponses == null ? '' : String(survey.maxResponses));
|
||||
let localRandomizeQuestions = $state(survey.randomizeQuestions ?? false);
|
||||
let localRandomizeOptions = $state(survey.randomizeOptions ?? false);
|
||||
let localPasswordEnabled = $state(survey.hasPassword ?? false);
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
function pushSnapshot() {
|
||||
if (isRestoring) return;
|
||||
if (isRestoring) {return;}
|
||||
clearTimeout(snapshotTimer);
|
||||
snapshotTimer = setTimeout(() => {
|
||||
const snap = takeSnapshot();
|
||||
@@ -106,11 +106,11 @@
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (undoStack.length === 0) return;
|
||||
if (undoStack.length === 0) {return;}
|
||||
isRestoring = true;
|
||||
const current = takeSnapshot();
|
||||
redoStack = [...redoStack, current];
|
||||
const snap = undoStack[undoStack.length - 1];
|
||||
const snap = undoStack.at(-1);
|
||||
undoStack = undoStack.slice(0, -1);
|
||||
localTitle = snap.title;
|
||||
localDescription = snap.description;
|
||||
@@ -122,11 +122,11 @@
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (redoStack.length === 0) return;
|
||||
if (redoStack.length === 0) {return;}
|
||||
isRestoring = true;
|
||||
const current = takeSnapshot();
|
||||
undoStack = [...undoStack, current];
|
||||
const snap = redoStack[redoStack.length - 1];
|
||||
const snap = redoStack.at(-1);
|
||||
redoStack = redoStack.slice(0, -1);
|
||||
localTitle = snap.title;
|
||||
localDescription = snap.description;
|
||||
@@ -200,7 +200,7 @@
|
||||
const newSection = createDefaultSection(localSections.length);
|
||||
localSections = [...localSections, newSection];
|
||||
tick().then(() => {
|
||||
const el = document.querySelector(`[data-section-index="${localSections.length - 1}"]`);
|
||||
const el = document.querySelector(`[data-section-index="${CSS.escape(localSections.length - 1)}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
@@ -270,8 +270,8 @@
|
||||
setTimeout(() => {
|
||||
success = false;
|
||||
}, 2500);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Failed to save';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@
|
||||
try {
|
||||
await handleSave();
|
||||
await onPublish();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to publish';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Failed to publish';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +293,8 @@
|
||||
error = null;
|
||||
try {
|
||||
await onUnpublish();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to unpublish';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Failed to unpublish';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-light/80 sticky top-0 z-20 border-b border-gray-200 backdrop-blur-md dark:border-gray-800">
|
||||
<div class="sticky top-0 z-20 border-b border-gray-200 bg-light/80 backdrop-blur-md dark:border-gray-800">
|
||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-6 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold tracking-tight">
|
||||
@@ -346,13 +346,13 @@
|
||||
<Icon icon={mdiClockOutline} size="13" />
|
||||
{estimatedTime}
|
||||
<Icon icon={mdiCircleSmall} size="12" />
|
||||
{totalQuestions} question{totalQuestions !== 1 ? 's' : ''}
|
||||
{totalQuestions} question{totalQuestions === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
disabled={undoStack.length === 0}
|
||||
onclick={undo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
@@ -360,7 +360,7 @@
|
||||
<Icon icon={mdiUndo} size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
|
||||
disabled={redoStack.length === 0}
|
||||
onclick={redo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative h-[667px] w-[375px] [transform:translateZ(0)] overflow-hidden rounded-[2.5rem] border-4 border-gray-600 bg-gray-950 shadow-2xl"
|
||||
class="relative h-[667px] w-[375px] transform-[translateZ(0)] overflow-hidden rounded-[2.5rem] border-4 border-gray-600 bg-gray-950 shadow-2xl"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-y-auto">
|
||||
{#if phase === 'welcome'}
|
||||
|
||||
@@ -7,7 +7,7 @@ function parseBulkPaste(text: string): { label: string; value: string }[] | null
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length < 2) return null;
|
||||
if (lines.length < 2) {return null;}
|
||||
return lines.map((l) => ({ label: l, value: l }));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file) {return;}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.addEventListener('load', (e) => {
|
||||
jsonText = (e.target?.result as string) ?? '';
|
||||
parseError = null;
|
||||
};
|
||||
});
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
onclick={() => onSelect(tag.id)}
|
||||
>
|
||||
{#if tag.color}
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full" style="background-color: {tag.color}"></span>
|
||||
<span class="mr-1 inline-block size-2 rounded-full" style="background-color: {tag.color}"></span>
|
||||
{/if}
|
||||
{tag.name}
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</span>
|
||||
<span class="text-sm text-gray-300">{auth.user.name || auth.user.email}</span>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
onclick={() => logout()}
|
||||
title="Log out"
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
const optionLabels = $derived.by(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const opt of question.options ?? []) map[opt.value] = opt.label;
|
||||
for (const opt of question.options ?? []) {map[opt.value] = opt.label;}
|
||||
return map;
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
let chart: ChartType | undefined;
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return '–';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds === null || !Number.isFinite(seconds)) {return '–';}
|
||||
if (seconds < 60) {return `${seconds}s`;}
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins < 60) return secs === 0 ? `${mins}m` : `${mins}m ${secs}s`;
|
||||
if (mins < 60) {return secs === 0 ? `${mins}m` : `${mins}m ${secs}s`;}
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remMin = mins % 60;
|
||||
return remMin === 0 ? `${hours}h` : `${hours}h ${remMin}m`;
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
const stats = $derived.by<StatEntry[]>(() => {
|
||||
if (data.count === 0) return [{ label: 'Sample', value: '0' }];
|
||||
if (data.count === 0) {return [{ label: 'Sample', value: '0' }];}
|
||||
return [
|
||||
{ label: 'Sample', value: String(data.count), hint: 'Number of completed responses in the distribution' },
|
||||
{
|
||||
@@ -48,10 +48,10 @@
|
||||
});
|
||||
|
||||
const medianBucketIndex = $derived.by(() => {
|
||||
if (data.median === null) return -1;
|
||||
if (data.median === null) {return -1;}
|
||||
for (let i = 0; i < data.buckets.length; i++) {
|
||||
const b = data.buckets[i];
|
||||
if (data.median >= b.minSeconds && (b.maxSeconds === null || data.median < b.maxSeconds)) return i;
|
||||
if (data.median >= b.minSeconds && (b.maxSeconds === null || data.median < b.maxSeconds)) {return i;}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
@@ -15,24 +15,24 @@
|
||||
const startingCohort = $derived(data[0]?.respondentsReached ?? data[0]?.respondentsAnswered ?? 0);
|
||||
|
||||
function widthPct(value: number): number {
|
||||
if (startingCohort <= 0) return 0;
|
||||
if (startingCohort <= 0) {return 0;}
|
||||
return Math.max(1, Math.min(100, (value / startingCohort) * 100));
|
||||
}
|
||||
|
||||
function severityClass(rate: number): string {
|
||||
if (rate >= 30) return 'bg-red-500/70';
|
||||
if (rate >= 15) return 'bg-amber-500/70';
|
||||
if (rate >= 30) {return 'bg-red-500/70';}
|
||||
if (rate >= 15) {return 'bg-amber-500/70';}
|
||||
return 'bg-emerald-500/70';
|
||||
}
|
||||
|
||||
function severityText(rate: number): string {
|
||||
if (rate >= 30) return 'text-red-400';
|
||||
if (rate >= 15) return 'text-amber-400';
|
||||
if (rate >= 30) {return 'text-red-400';}
|
||||
if (rate >= 15) {return 'text-amber-400';}
|
||||
return 'text-emerald-400';
|
||||
}
|
||||
|
||||
function retainedPct(row: DropoffDataPoint): number {
|
||||
if (startingCohort <= 0) return 0;
|
||||
if (startingCohort <= 0) {return 0;}
|
||||
return Math.round((row.respondentsReached / startingCohort) * 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
const filteredEntries = $derived.by<EmailEntry[]>(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return summary.entries.filter((e) => {
|
||||
if (filter === 'corporate' && e.kind !== 'corporate') return false;
|
||||
if (filter === 'free' && e.kind !== 'free') return false;
|
||||
if (filter === 'disposable' && e.kind !== 'disposable') return false;
|
||||
if (filter === 'role' && !e.isRoleBased) return false;
|
||||
if (q && !e.normalized.includes(q) && !e.raw.toLowerCase().includes(q)) return false;
|
||||
if (filter === 'corporate' && e.kind !== 'corporate') {return false;}
|
||||
if (filter === 'free' && e.kind !== 'free') {return false;}
|
||||
if (filter === 'disposable' && e.kind !== 'disposable') {return false;}
|
||||
if (filter === 'role' && !e.isRoleBased) {return false;}
|
||||
if (q && !e.normalized.includes(q) && !e.raw.toLowerCase().includes(q)) {return false;}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -102,16 +102,16 @@
|
||||
const maxDomainCount = $derived(Math.max(1, ...summary.topDomains.map((d) => d.count)));
|
||||
|
||||
function domainColor(kind: EmailKind): string {
|
||||
if (kind === 'corporate') return 'bg-emerald-500/70';
|
||||
if (kind === 'free') return 'bg-blue-500/70';
|
||||
if (kind === 'corporate') {return 'bg-emerald-500/70';}
|
||||
if (kind === 'free') {return 'bg-blue-500/70';}
|
||||
return 'bg-red-500/70';
|
||||
}
|
||||
|
||||
function filterCount(kind: FilterKind): number {
|
||||
if (kind === 'all') return summary.unique;
|
||||
if (kind === 'corporate') return summary.entries.filter((e) => e.kind === 'corporate').length;
|
||||
if (kind === 'free') return summary.entries.filter((e) => e.kind === 'free').length;
|
||||
if (kind === 'disposable') return summary.entries.filter((e) => e.kind === 'disposable').length;
|
||||
if (kind === 'all') {return summary.unique;}
|
||||
if (kind === 'corporate') {return summary.entries.filter((e) => e.kind === 'corporate').length;}
|
||||
if (kind === 'free') {return summary.entries.filter((e) => e.kind === 'free').length;}
|
||||
if (kind === 'disposable') {return summary.entries.filter((e) => e.kind === 'disposable').length;}
|
||||
return summary.entries.filter((e) => e.isRoleBased).length;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = which;
|
||||
setTimeout(() => {
|
||||
if (copied === which) copied = null;
|
||||
if (copied === which) {copied = null;}
|
||||
}, 1500);
|
||||
} catch {
|
||||
// ignore clipboard errors — user can select manually
|
||||
@@ -152,18 +152,23 @@
|
||||
|
||||
function filterChipClass(key: FilterKind): string {
|
||||
const active = filter === key;
|
||||
if (!active) return 'border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-300 dark:border-gray-700';
|
||||
if (!active) {return 'border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-300 dark:border-gray-700';}
|
||||
switch (key) {
|
||||
case 'corporate':
|
||||
case 'corporate': {
|
||||
return 'border-emerald-500/60 bg-emerald-500/10 text-emerald-400';
|
||||
case 'free':
|
||||
}
|
||||
case 'free': {
|
||||
return 'border-blue-500/60 bg-blue-500/10 text-blue-400';
|
||||
case 'disposable':
|
||||
}
|
||||
case 'disposable': {
|
||||
return 'border-red-500/60 bg-red-500/10 text-red-400';
|
||||
case 'role':
|
||||
}
|
||||
case 'role': {
|
||||
return 'border-amber-500/60 bg-amber-500/10 text-amber-400';
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return 'border-gray-500 bg-gray-500/10 text-gray-200';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<div>
|
||||
<div class="text-[10px] font-medium tracking-wider text-gray-500 uppercase">Mean</div>
|
||||
<div class="mt-0.5 text-2xl font-bold text-gray-200 tabular-nums">
|
||||
{stats.mean !== null ? stats.mean.toFixed(2) : '–'}
|
||||
{stats.mean === null ? '–' : stats.mean.toFixed(2)}
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-500">1–5 scale</div>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
{#if stats.total > 0}
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute top-0 bottom-0 left-1/2 z-10 w-px bg-gray-500/50"></div>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px bg-gray-500/50"></div>
|
||||
|
||||
<div class="flex h-8 w-full overflow-hidden rounded-md bg-gray-200/30 dark:bg-gray-800/40">
|
||||
<div style="width: {leftPad}%" class="shrink-0"></div>
|
||||
@@ -139,13 +139,13 @@
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-gray-400">
|
||||
<span class="flex items-center gap-1.5"
|
||||
><span class="h-2 w-2 rounded-sm bg-red-600/85"></span>Strongly Disagree</span
|
||||
><span class="size-2 rounded-sm bg-red-600/85"></span>Strongly Disagree</span
|
||||
>
|
||||
<span class="flex items-center gap-1.5"><span class="h-2 w-2 rounded-sm bg-red-400/85"></span>Disagree</span>
|
||||
<span class="flex items-center gap-1.5"><span class="h-2 w-2 rounded-sm bg-gray-400/60"></span>Neutral</span>
|
||||
<span class="flex items-center gap-1.5"><span class="h-2 w-2 rounded-sm bg-green-400/85"></span>Agree</span>
|
||||
<span class="flex items-center gap-1.5"><span class="size-2 rounded-sm bg-red-400/85"></span>Disagree</span>
|
||||
<span class="flex items-center gap-1.5"><span class="size-2 rounded-sm bg-gray-400/60"></span>Neutral</span>
|
||||
<span class="flex items-center gap-1.5"><span class="size-2 rounded-sm bg-green-400/85"></span>Agree</span>
|
||||
<span class="flex items-center gap-1.5"
|
||||
><span class="h-2 w-2 rounded-sm bg-green-600/85"></span>Strongly Agree</span
|
||||
><span class="size-2 rounded-sm bg-green-600/85"></span>Strongly Agree</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-full border border-gray-300 px-3 py-1.5 text-xs dark:border-gray-600">
|
||||
<span class="pulse-dot inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span class="pulse-dot inline-block size-2 rounded-full bg-green-500"></span>
|
||||
{#if counts.activeRespondents > 0}
|
||||
<span class="text-gray-400">
|
||||
<span class="font-medium text-green-400">{counts.activeRespondents}</span> responding
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
const maxBucket = $derived(Math.max(1, ...dist.map((d) => d.count)));
|
||||
|
||||
function scoreColour(score: number | null): string {
|
||||
if (score === null) return 'text-gray-400';
|
||||
if (score >= 50) return 'text-green-400';
|
||||
if (score >= 0) return 'text-emerald-400';
|
||||
if (score >= -50) return 'text-amber-400';
|
||||
if (score === null) {return 'text-gray-400';}
|
||||
if (score >= 50) {return 'text-green-400';}
|
||||
if (score >= 0) {return 'text-emerald-400';}
|
||||
if (score >= -50) {return 'text-amber-400';}
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function bucketColour(score: number): string {
|
||||
if (score <= 6) return 'bg-red-500/70';
|
||||
if (score <= 8) return 'bg-amber-500/70';
|
||||
if (score <= 6) {return 'bg-red-500/70';}
|
||||
if (score <= 8) {return 'bg-amber-500/70';}
|
||||
return 'bg-green-500/70';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
const maxBucket = $derived(Math.max(1, ...buckets.map((b) => b.count)));
|
||||
|
||||
function fmt(n: number | null, digits = 1): string {
|
||||
if (n === null) return '–';
|
||||
if (n === null) {return '–';}
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(digits);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
for (const card of statCards) {
|
||||
const label = card.querySelector('.uppercase')?.textContent?.trim() ?? '';
|
||||
const value = card.querySelector('.text-2xl')?.textContent?.trim() ?? '';
|
||||
if (label && value) stats.push(`${label}: ${value}`);
|
||||
if (label && value) {stats.push(`${label}: ${value}`);}
|
||||
}
|
||||
if (stats.length > 0) {
|
||||
addText(stats.join(' • '), 11, false, [156, 163, 175]);
|
||||
@@ -78,7 +78,7 @@
|
||||
const chartContainers = element.querySelectorAll('.rounded-xl');
|
||||
for (const container of chartContainers) {
|
||||
const heading = container.querySelector('h3')?.textContent?.trim();
|
||||
if (!heading) continue;
|
||||
if (!heading) {continue;}
|
||||
|
||||
addSpace(3);
|
||||
addText(heading, 13, true);
|
||||
@@ -113,7 +113,7 @@
|
||||
}
|
||||
|
||||
// Text answers (non-chart questions)
|
||||
const answerCards = container.querySelectorAll('.bg-gray-100, .dark\\:bg-gray-800');
|
||||
const answerCards = container.querySelectorAll(String.raw`.bg-gray-100, .dark\:bg-gray-800`);
|
||||
if (answerCards.length > 0 && !canvas) {
|
||||
for (const card of Array.from(answerCards).slice(0, 15)) {
|
||||
const text = card.querySelector('p')?.textContent?.trim();
|
||||
@@ -128,8 +128,8 @@
|
||||
}
|
||||
|
||||
pdf.save(filename);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'PDF export failed';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'PDF export failed';
|
||||
setTimeout(() => (error = null), 4000);
|
||||
}
|
||||
exporting = false;
|
||||
@@ -147,7 +147,7 @@
|
||||
</button>
|
||||
{#if error}
|
||||
<span
|
||||
class="absolute top-full right-0 mt-1 rounded bg-red-600 px-2 py-1 text-xs whitespace-nowrap text-white shadow-lg"
|
||||
class="absolute top-full right-0 mt-1 rounded-sm bg-red-600 px-2 py-1 text-xs whitespace-nowrap text-white shadow-lg"
|
||||
>{error}</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answers: AnswerData[];
|
||||
/** Total number of completed respondents for this survey (denominator for % calculations). */
|
||||
/**
|
||||
Total number of completed respondents for this survey (denominator for % calculations).
|
||||
*/
|
||||
totalResponses: number;
|
||||
onViewAllResponses?: () => void;
|
||||
}
|
||||
@@ -44,7 +46,7 @@
|
||||
<div class="rounded-xl border border-gray-300 p-5 dark:border-gray-700">
|
||||
<div class="mb-4">
|
||||
<div class="mb-1 flex items-start justify-between gap-3">
|
||||
<h3 class="text-base leading-snug font-semibold">{question.text}</h3>
|
||||
<h3 class="text-base/snug font-semibold">{question.text}</h3>
|
||||
<span
|
||||
class="shrink-0 rounded-full border border-gray-700 px-2 py-0.5 text-[10px] tracking-wider text-gray-500 uppercase"
|
||||
>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null || !Number.isFinite(ms)) return '–';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms === null || !Number.isFinite(ms)) {return '–';}
|
||||
if (ms < 1000) {return `${ms}ms`;}
|
||||
const secs = Math.round(ms / 100) / 10;
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 60) {return `${secs}s`;}
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remSec = Math.round(secs - mins * 60);
|
||||
return remSec === 0 ? `${mins}m` : `${mins}m ${remSec}s`;
|
||||
@@ -25,12 +25,12 @@
|
||||
*/
|
||||
const xMax = $derived.by(() => {
|
||||
const p95s = data.map((q) => q.p95Ms ?? 0).filter((v) => v > 0);
|
||||
if (p95s.length === 0) return 1;
|
||||
if (p95s.length === 0) {return 1;}
|
||||
return Math.max(...p95s) * 1.05;
|
||||
});
|
||||
|
||||
function xPct(ms: number | null): number {
|
||||
if (ms === null || !Number.isFinite(ms) || xMax <= 0) return 0;
|
||||
if (ms === null || !Number.isFinite(ms) || xMax <= 0) {return 0;}
|
||||
return Math.max(0, Math.min(100, (ms / xMax) * 100));
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
* actual max.
|
||||
*/
|
||||
const ticks = $derived.by(() => {
|
||||
if (xMax <= 0) return [] as Array<{ ms: number; label: string }>;
|
||||
if (xMax <= 0) {return [] as Array<{ ms: number; label: string }>;}
|
||||
const targetTicks = 5;
|
||||
const rough = xMax / targetTicks;
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||
const candidates = [1, 2, 5, 10].map((m) => m * pow);
|
||||
const step = candidates.find((c) => c >= rough) ?? candidates[candidates.length - 1];
|
||||
const step = candidates.find((c) => c >= rough) ?? candidates.at(-1);
|
||||
const out: Array<{ ms: number; label: string }> = [];
|
||||
for (let v = 0; v <= xMax; v += step) {
|
||||
out.push({ ms: v, label: formatDuration(Math.round(v)) });
|
||||
@@ -82,7 +82,7 @@
|
||||
n={row.sampleSize} · median {formatDuration(row.medianMs)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative h-5 rounded bg-gray-800/30">
|
||||
<div class="relative h-5 rounded-sm bg-gray-800/30">
|
||||
{#if row.sampleSize > 0}
|
||||
{@const p5 = xPct(row.p5Ms)}
|
||||
{@const p25 = xPct(row.p25Ms)}
|
||||
@@ -96,7 +96,7 @@
|
||||
<div class="absolute top-1/2 h-2 w-px -translate-y-1/2 bg-purple-400/50" style="left: {p5}%"></div>
|
||||
<div class="absolute top-1/2 h-2 w-px -translate-y-1/2 bg-purple-400/50" style="left: {p95}%"></div>
|
||||
<div
|
||||
class="absolute top-1/2 h-3 -translate-y-1/2 rounded bg-purple-500/60"
|
||||
class="absolute top-1/2 h-3 -translate-y-1/2 rounded-sm bg-purple-500/60"
|
||||
style="left: {p25}%; width: {Math.max(0.5, p75 - p25)}%"
|
||||
></div>
|
||||
<div
|
||||
@@ -115,7 +115,7 @@
|
||||
</div>
|
||||
|
||||
<div class="relative mt-2 ml-9 h-4 sm:mr-20">
|
||||
<div class="absolute top-0 right-0 left-0 h-px bg-gray-700"></div>
|
||||
<div class="absolute inset-x-0 top-0 h-px bg-gray-700"></div>
|
||||
{#each ticks as t (t.ms)}
|
||||
<div class="absolute top-0 -translate-x-1/2" style="left: {xPct(t.ms)}%">
|
||||
<div class="h-1 w-px bg-gray-700"></div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
);
|
||||
|
||||
function renderStars(mean: number | null): { full: number; half: boolean; empty: number } {
|
||||
if (mean === null) return { full: 0, half: false, empty: scaleMax };
|
||||
if (mean === null) {return { full: 0, half: false, empty: scaleMax };}
|
||||
const full = Math.floor(mean);
|
||||
const half = mean - full >= 0.25 && mean - full < 0.75;
|
||||
const fullShown = half ? full : Math.round(mean);
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="text-[10px] font-medium tracking-wider text-gray-500 uppercase">Average</div>
|
||||
<div class="mt-0.5 flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold text-gray-200 tabular-nums">
|
||||
{stats.mean !== null ? stats.mean.toFixed(1) : '–'}
|
||||
{stats.mean === null ? '–' : stats.mean.toFixed(1)}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">/ {scaleMax}</span>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
async function handleDelete(event: MouseEvent, respondentId: string) {
|
||||
event.stopPropagation();
|
||||
if (!confirm('Delete this response and all its answers? This cannot be undone.')) return;
|
||||
if (!confirm('Delete this response and all its answers? This cannot be undone.')) {return;}
|
||||
deletingId = respondentId;
|
||||
try {
|
||||
await deleteRespondent(surveyId, respondentId);
|
||||
|
||||
@@ -14,14 +14,18 @@
|
||||
|
||||
function toneClass(tone?: Stat['tone']): string {
|
||||
switch (tone) {
|
||||
case 'positive':
|
||||
case 'positive': {
|
||||
return 'text-green-400';
|
||||
case 'negative':
|
||||
}
|
||||
case 'negative': {
|
||||
return 'text-red-400';
|
||||
case 'warning':
|
||||
}
|
||||
case 'warning': {
|
||||
return 'text-amber-400';
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return 'text-gray-200';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,13 +24,15 @@
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Deduped so the sample below never shows the same answer twice. */
|
||||
/**
|
||||
Deduped so the sample below never shows the same answer twice.
|
||||
*/
|
||||
const uniqueResponses = $derived.by<SampleEntry[]>(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
const order: string[] = [];
|
||||
for (const a of answers) {
|
||||
const text = a.value.trim();
|
||||
if (!text) continue;
|
||||
if (!text) {continue;}
|
||||
if (counts[text] === undefined) {
|
||||
counts[text] = 0;
|
||||
order.push(text);
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
totalResults = data.total;
|
||||
currentOffset = offset;
|
||||
} catch {
|
||||
if (offset === 0) results = [];
|
||||
if (offset === 0) {results = [];}
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
selectedMatchQuestionId = result.questionId;
|
||||
detailError = null;
|
||||
// If we already have this respondent loaded, just update the highlight
|
||||
if (selectedDetail?.id === result.respondentId) return;
|
||||
if (selectedDetail?.id === result.respondentId) {return;}
|
||||
selectedDetail = null;
|
||||
loadingDetail = true;
|
||||
try {
|
||||
@@ -160,7 +160,7 @@
|
||||
<Icon icon={expanded ? mdiChevronUp : mdiChevronDown} size="16" class="mt-0.5 shrink-0 text-gray-500" />
|
||||
</button>
|
||||
{#if expanded}
|
||||
<div class="border-t border-gray-200 px-3 pt-3 pb-3 dark:border-gray-700">
|
||||
<div class="border-t border-gray-200 p-3 dark:border-gray-700">
|
||||
{#if loadingDetail}
|
||||
<p class="py-4 text-center text-xs text-gray-500">Loading full response…</p>
|
||||
{:else if detailError}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
* how SQLite stored them.
|
||||
*/
|
||||
function parsePeriod(period: string, g: Granularity): Date {
|
||||
if (g === 'day') return new Date(`${period}T00:00:00Z`);
|
||||
if (g === 'hour') return new Date(`${period}:00:00Z`);
|
||||
if (g === 'day') {return new Date(`${period}T00:00:00Z`);}
|
||||
if (g === 'hour') {return new Date(`${period}:00:00Z`);}
|
||||
return new Date(`${period}:00Z`);
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
const day = pad(d.getUTCDate());
|
||||
const h = pad(d.getUTCHours());
|
||||
const min = pad(d.getUTCMinutes());
|
||||
if (g === 'day') return `${y}-${m}-${day}`;
|
||||
if (g === 'hour') return `${y}-${m}-${day}T${h}`;
|
||||
if (g === 'day') {return `${y}-${m}-${day}`;}
|
||||
if (g === 'hour') {return `${y}-${m}-${day}T${h}`;}
|
||||
return `${y}-${m}-${day}T${h}:${min}`;
|
||||
}
|
||||
|
||||
function stepMs(g: Granularity): number {
|
||||
if (g === 'day') return 24 * 60 * 60 * 1000;
|
||||
if (g === 'hour') return 60 * 60 * 1000;
|
||||
if (g === 'day') {return 24 * 60 * 60 * 1000;}
|
||||
if (g === 'hour') {return 60 * 60 * 1000;}
|
||||
return 60 * 1000;
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@
|
||||
* runs up to the present.
|
||||
*/
|
||||
function fillGaps(points: TimelineDataPoint[], g: Granularity): TimelineDataPoint[] {
|
||||
if (points.length === 0) return [];
|
||||
if (points.length === 0) {return [];}
|
||||
const step = stepMs(g);
|
||||
const firstReal = parsePeriod(points[0].period, g).getTime();
|
||||
const lastReal = parsePeriod(points[points.length - 1].period, g).getTime();
|
||||
const lastReal = parsePeriod(points.at(-1).period, g).getTime();
|
||||
|
||||
// Round "now" down to the current bucket boundary, UTC-aligned to the UNIX
|
||||
// epoch — matches how SQLite groups periods via substr on the ISO timestamp.
|
||||
@@ -67,7 +67,7 @@
|
||||
const head = span > maxPoints ? tail - (maxPoints - 1) * step : firstReal;
|
||||
|
||||
const byKey: Record<string, TimelineDataPoint> = {};
|
||||
for (const p of points) byKey[p.period] = p;
|
||||
for (const p of points) {byKey[p.period] = p;}
|
||||
|
||||
const filled: TimelineDataPoint[] = [];
|
||||
for (let t = head; t <= tail; t += step) {
|
||||
@@ -83,13 +83,13 @@
|
||||
function tickLabel(period: string, g: Granularity): string {
|
||||
const d = parsePeriod(period, g);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
if (g === 'minute') return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
if (g === 'hour') return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}h`;
|
||||
if (g === 'minute') {return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;}
|
||||
if (g === 'hour') {return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}h`;}
|
||||
return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas || filledData.length === 0) return;
|
||||
if (!canvas || filledData.length === 0) {return;}
|
||||
|
||||
const { isDark, textColor, gridColor } = getChartColors();
|
||||
const startedColor = isDark ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)';
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('computeTextStats', () => {
|
||||
|
||||
it('counts blank, short and long buckets', () => {
|
||||
const answers: AnswerData[] = [
|
||||
{ value: ' ', count: 2 }, // blank
|
||||
{ value: ' '.repeat(3), count: 2 }, // blank
|
||||
{ value: 'hi', count: 1 }, // short (<10)
|
||||
{ value: 'a'.repeat(50), count: 1 }, // medium (not counted)
|
||||
{ value: 'a'.repeat(150), count: 3 }, // long (>100)
|
||||
@@ -493,7 +493,7 @@ describe('bucketNumbers', () => {
|
||||
it('includes the maximum value in the last bucket', () => {
|
||||
const values = Array.from({ length: 100 }, (_, i) => i);
|
||||
const buckets = bucketNumbers(values);
|
||||
const last = buckets[buckets.length - 1];
|
||||
const last = buckets.at(-1);
|
||||
expect(last.rangeEnd).toBe(99);
|
||||
expect(last.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -22,11 +22,11 @@ export function computeNps(answers: AnswerData[]): NpsStats {
|
||||
detractors = 0;
|
||||
for (const a of answers) {
|
||||
const score = Number(a.value);
|
||||
if (Number.isNaN(score)) continue;
|
||||
if (Number.isNaN(score)) {continue;}
|
||||
total += a.count;
|
||||
if (score >= 9) promoters += a.count;
|
||||
else if (score >= 7) passives += a.count;
|
||||
else detractors += a.count;
|
||||
if (score >= 9) {promoters += a.count;}
|
||||
else if (score >= 7) {passives += a.count;}
|
||||
else {detractors += a.count;}
|
||||
}
|
||||
const npsScore = total > 0 ? Math.round(((promoters - detractors) / total) * 100) : null;
|
||||
const pPct = total > 0 ? (promoters / total) * 100 : 0;
|
||||
@@ -36,10 +36,10 @@ export function computeNps(answers: AnswerData[]): NpsStats {
|
||||
}
|
||||
|
||||
export function npsLabel(npsScore: number | null): string {
|
||||
if (npsScore === null) return '';
|
||||
if (npsScore >= 50) return 'Excellent';
|
||||
if (npsScore >= 0) return 'Good';
|
||||
if (npsScore >= -50) return 'Needs improvement';
|
||||
if (npsScore === null) {return '';}
|
||||
if (npsScore >= 50) {return 'Excellent';}
|
||||
if (npsScore >= 0) {return 'Good';}
|
||||
if (npsScore >= -50) {return 'Needs improvement';}
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function npsDistribution(answers: AnswerData[]): Array<{ score: number; c
|
||||
const dist = Array.from({ length: 11 }, (_, i) => ({ score: i, count: 0 }));
|
||||
for (const a of answers) {
|
||||
const n = Number(a.value);
|
||||
if (Number.isInteger(n) && n >= 0 && n <= 10) dist[n].count += a.count;
|
||||
if (Number.isInteger(n) && n >= 0 && n <= 10) {dist[n].count += a.count;}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
@@ -96,13 +96,13 @@ export interface LikertStats {
|
||||
|
||||
export function computeLikert(answers: AnswerData[]): LikertStats {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const v of LIKERT_VALUES) counts[v] = 0;
|
||||
for (const v of LIKERT_VALUES) {counts[v] = 0;}
|
||||
|
||||
let total = 0;
|
||||
let sum = 0;
|
||||
for (const a of answers) {
|
||||
const idx = LIKERT_VALUES.indexOf(a.value as (typeof LIKERT_VALUES)[number]);
|
||||
if (idx >= 0) {
|
||||
if (idx !== -1) {
|
||||
counts[a.value] += a.count;
|
||||
total += a.count;
|
||||
sum += (idx + 1) * a.count;
|
||||
@@ -139,7 +139,7 @@ export function computeNumber(answers: AnswerData[]): NumberStats {
|
||||
for (const a of answers) {
|
||||
const n = Number(a.value);
|
||||
if (Number.isFinite(n)) {
|
||||
for (let i = 0; i < a.count; i++) values.push(n);
|
||||
for (let i = 0; i < a.count; i++) {values.push(n);}
|
||||
}
|
||||
}
|
||||
values.sort((a, b) => a - b);
|
||||
@@ -160,7 +160,9 @@ export function computeNumber(answers: AnswerData[]): NumberStats {
|
||||
};
|
||||
}
|
||||
|
||||
/** Auto-bucket numeric values into ~10 bins using Sturges-like rules. */
|
||||
/**
|
||||
Auto-bucket numeric values into ~10 bins using Sturges-like rules.
|
||||
*/
|
||||
export interface NumberBucket {
|
||||
label: string;
|
||||
rangeStart: number;
|
||||
@@ -169,9 +171,9 @@ export interface NumberBucket {
|
||||
}
|
||||
|
||||
export function bucketNumbers(values: number[], maxBuckets = 10): NumberBucket[] {
|
||||
if (values.length === 0) return [];
|
||||
if (values.length === 0) {return [];}
|
||||
const min = values[0];
|
||||
const max = values[values.length - 1];
|
||||
const max = values.at(-1);
|
||||
if (min === max) {
|
||||
return [{ label: `${min}`, rangeStart: min, rangeEnd: min, count: values.length }];
|
||||
}
|
||||
@@ -199,18 +201,14 @@ export function bucketNumbers(values: number[], maxBuckets = 10): NumberBucket[]
|
||||
for (const v of values) {
|
||||
// last bucket is inclusive on both ends
|
||||
let idx: number;
|
||||
if (v === max) {
|
||||
idx = bucketCount - 1;
|
||||
} else {
|
||||
idx = Math.min(bucketCount - 1, Math.floor((v - min) / bucketSize));
|
||||
}
|
||||
idx = v === max ? bucketCount - 1 : Math.min(bucketCount - 1, Math.floor((v - min) / bucketSize));
|
||||
buckets[idx].count++;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
if (Number.isInteger(n)) {return String(n);}
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
@@ -319,7 +317,7 @@ const STOPWORDS = new Set([
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s'-]/g, ' ')
|
||||
.replaceAll(/[^\w\s'-]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 1 && !STOPWORDS.has(w));
|
||||
}
|
||||
@@ -329,7 +327,9 @@ export interface NgramEntry {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** N-grams rather than a word cloud: counts stay explicit and phrases are preserved. */
|
||||
/**
|
||||
N-grams rather than a word cloud: counts stay explicit and phrases are preserved.
|
||||
*/
|
||||
export function computeNgrams(answers: AnswerData[], limit = 20): NgramEntry[] {
|
||||
const phrases = new Map<string, number>();
|
||||
|
||||
@@ -351,7 +351,7 @@ export function computeNgrams(answers: AnswerData[], limit = 20): NgramEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
return [...phrases.entries()]
|
||||
return [...phrases]
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
.filter((e) => e.count >= 2 || e.phrase.split(' ').length === 1)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
@@ -377,9 +377,9 @@ export function computeTextStats(answers: AnswerData[]): TextStats {
|
||||
const trimmed = a.value.trim();
|
||||
total += a.count;
|
||||
totalChars += trimmed.length * a.count;
|
||||
if (trimmed.length === 0) blankCount += a.count;
|
||||
else if (trimmed.length < 10) shortCount += a.count;
|
||||
else if (trimmed.length > 100) longCount += a.count;
|
||||
if (trimmed.length === 0) {blankCount += a.count;}
|
||||
else if (trimmed.length < 10) {shortCount += a.count;}
|
||||
else if (trimmed.length > 100) {longCount += a.count;}
|
||||
}
|
||||
return {
|
||||
total,
|
||||
@@ -497,33 +497,37 @@ export type EmailKind = 'corporate' | 'free' | 'disposable';
|
||||
export function normalizeEmail(raw: string): { normalized: string; local: string; domain: string } | null {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
const at = trimmed.lastIndexOf('@');
|
||||
if (at <= 0 || at >= trimmed.length - 1) return null;
|
||||
if (at <= 0 || at >= trimmed.length - 1) {return null;}
|
||||
let local = trimmed.slice(0, at);
|
||||
const domain = trimmed.slice(at + 1);
|
||||
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null;
|
||||
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {return null;}
|
||||
if (domain === 'gmail.com' || domain === 'googlemail.com') {
|
||||
const plus = local.indexOf('+');
|
||||
if (plus >= 0) local = local.slice(0, plus);
|
||||
if (plus !== -1) {local = local.slice(0, plus);}
|
||||
local = local.replaceAll('.', '');
|
||||
}
|
||||
return { normalized: `${local}@${domain}`, local, domain };
|
||||
}
|
||||
|
||||
export function classifyDomain(domain: string): EmailKind {
|
||||
if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) return 'disposable';
|
||||
if (FREE_EMAIL_DOMAINS.has(domain)) return 'free';
|
||||
if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) {return 'disposable';}
|
||||
if (FREE_EMAIL_DOMAINS.has(domain)) {return 'free';}
|
||||
return 'corporate';
|
||||
}
|
||||
|
||||
export interface EmailEntry {
|
||||
/** Original casing as submitted (first seen). */
|
||||
/**
|
||||
Original casing as submitted (first seen).
|
||||
*/
|
||||
raw: string;
|
||||
normalized: string;
|
||||
local: string;
|
||||
domain: string;
|
||||
kind: EmailKind;
|
||||
isRoleBased: boolean;
|
||||
/** How many respondents submitted this (deduped) address. */
|
||||
/**
|
||||
How many respondents submitted this (deduped) address.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -580,13 +584,13 @@ export function computeEmailSummary(answers: AnswerData[]): EmailSummary {
|
||||
let corporateCount = 0;
|
||||
let roleBasedCount = 0;
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'disposable') disposableCount += e.count;
|
||||
else if (e.kind === 'free') freeCount += e.count;
|
||||
else corporateCount += e.count;
|
||||
if (e.isRoleBased) roleBasedCount += e.count;
|
||||
if (e.kind === 'disposable') {disposableCount += e.count;}
|
||||
else if (e.kind === 'free') {freeCount += e.count;}
|
||||
else {corporateCount += e.count;}
|
||||
if (e.isRoleBased) {roleBasedCount += e.count;}
|
||||
}
|
||||
|
||||
const topDomains = [...domains.entries()]
|
||||
const topDomains = [...domains]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([domain, count]) => ({ domain, count, kind: classifyDomain(domain) }));
|
||||
@@ -658,7 +662,7 @@ export function computeCheckboxStats(answers: AnswerData[]): CheckboxStats {
|
||||
}
|
||||
}
|
||||
|
||||
const perOptionArr = [...perOption.entries()]
|
||||
const perOptionArr = [...perOption]
|
||||
.map(([value, count]) => ({
|
||||
value,
|
||||
count,
|
||||
@@ -675,7 +679,7 @@ export function computeCheckboxStats(answers: AnswerData[]): CheckboxStats {
|
||||
}
|
||||
|
||||
export function computeDropoffRate(reached: number, answered: number): number {
|
||||
if (reached === 0) return 0;
|
||||
if (reached === 0) {return 0;}
|
||||
return Math.round(((reached - answered) / reached) * 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function getChartColors() {
|
||||
const isDark = typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = typeof window !== 'undefined' && globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return {
|
||||
textColor: isDark ? 'rgb(156, 163, 175)' : 'rgb(107, 114, 128)',
|
||||
gridColor: isDark ? 'rgb(55, 65, 81)' : 'rgb(229, 231, 235)',
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
href={twitterUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
title="Share on X / Twitter"
|
||||
>
|
||||
<Icon icon={mdiTwitter} size="16" />
|
||||
@@ -41,20 +41,20 @@
|
||||
href={linkedinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
title="Share on LinkedIn"
|
||||
>
|
||||
<Icon icon={mdiLinkedin} size="16" />
|
||||
</a>
|
||||
<a
|
||||
href={emailUrl}
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
title="Share via email"
|
||||
>
|
||||
<Icon icon={mdiEmail} size="16" />
|
||||
</a>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
|
||||
onclick={copyLink}
|
||||
title="Copy link"
|
||||
>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
let { slug }: Props = $props();
|
||||
|
||||
async function startOver() {
|
||||
await fetch(`/api/s/${slug}/reset`, { method: 'POST', credentials: 'same-origin' });
|
||||
window.location.reload();
|
||||
await fetch(`/api/s/${slug}/reset`, { method: 'POST'});
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
aria-checked={selectedValues.has(option.value)}
|
||||
>
|
||||
<span
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md {selectedValues.has(option.value)
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md {selectedValues.has(option.value)
|
||||
? 'bg-immich-primary text-white'
|
||||
: 'border-2 border-gray-300 dark:border-gray-500'}"
|
||||
>
|
||||
@@ -68,7 +68,7 @@
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||
>
|
||||
<button
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md {selectedValues.has('Other')
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md {selectedValues.has('Other')
|
||||
? 'bg-immich-primary text-white'
|
||||
: 'border-2 border-gray-300 dark:border-gray-500'}"
|
||||
onclick={() => toggle('Other')}
|
||||
@@ -83,11 +83,11 @@
|
||||
placeholder={question.otherPrompt ?? 'Other...'}
|
||||
bind:value={otherText}
|
||||
onfocus={() => {
|
||||
if (!selectedValues.has('Other')) toggle('Other');
|
||||
if (!selectedValues.has('Other')) {toggle('Other');}
|
||||
}}
|
||||
oninput={() => {
|
||||
const vals = [...selectedValues];
|
||||
if (!vals.includes('Other')) vals.push('Other');
|
||||
if (!vals.includes('Other')) {vals.push('Other');}
|
||||
onAnswer(vals.join(','), otherText);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:gap-1.5">
|
||||
{#each labels as label (label)}
|
||||
<button
|
||||
class="flex-1 rounded-lg border-2 px-3 py-3 text-sm font-medium transition-all duration-150
|
||||
class="flex-1 rounded-lg border-2 p-3 text-sm font-medium transition-all duration-150
|
||||
{answer?.value === label
|
||||
? 'border-immich-primary bg-immich-primary-10 text-immich-primary'
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
|
||||
let { question, answer, onAnswer }: Props = $props();
|
||||
|
||||
const selectedValue = $derived(answer?.value !== undefined ? Number(answer.value) : -1);
|
||||
const selectedValue = $derived(answer?.value === undefined ? -1 : Number(answer.value));
|
||||
|
||||
function getColor(n: number, isSelected: boolean): string {
|
||||
if (isSelected) {
|
||||
if (n <= 6) return 'bg-red-500 text-white border-red-500';
|
||||
if (n <= 8) return 'bg-amber-500 text-white border-amber-500';
|
||||
if (n <= 6) {return 'bg-red-500 text-white border-red-500';}
|
||||
if (n <= 8) {return 'bg-amber-500 text-white border-amber-500';}
|
||||
return 'bg-green-500 text-white border-green-500';
|
||||
}
|
||||
if (n <= 6) return 'border-red-300 text-red-400 hover:bg-red-500/10 dark:border-red-700 dark:text-red-400';
|
||||
if (n <= 6) {return 'border-red-300 text-red-400 hover:bg-red-500/10 dark:border-red-700 dark:text-red-400';}
|
||||
if (n <= 8)
|
||||
return 'border-amber-300 text-amber-500 hover:bg-amber-500/10 dark:border-amber-600 dark:text-amber-400';
|
||||
{return 'border-amber-300 text-amber-500 hover:bg-amber-500/10 dark:border-amber-600 dark:text-amber-400';}
|
||||
return 'border-green-300 text-green-500 hover:bg-green-500/10 dark:border-green-700 dark:text-green-400';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
const max = $derived(question.config?.max);
|
||||
const hasHint = $derived(min !== undefined || max !== undefined);
|
||||
const hintText = $derived(
|
||||
[min !== undefined ? `Min: ${min}` : '', max !== undefined ? `Max: ${max}` : ''].filter(Boolean).join(', '),
|
||||
[min === undefined ? '' : `Min: ${min}`, max === undefined ? '' : `Max: ${max}`].filter(Boolean).join(', '),
|
||||
);
|
||||
|
||||
// `bind:value` on a type="number" input yields a number, but answers are
|
||||
// strings end to end (validateAnswer/server call value.trim()). Coerce here so
|
||||
// a number never leaks into the answer pipeline.
|
||||
const { handleInput } = useDebouncedAnswer(
|
||||
() => String(numberValue ?? ''),
|
||||
() => (numberValue ?? ''),
|
||||
(v) => onAnswer(v),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) return;
|
||||
if (!password.trim()) {return;}
|
||||
submitting = true;
|
||||
error = null;
|
||||
try {
|
||||
await onSubmit(password);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Incorrect password';
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Incorrect password';
|
||||
}
|
||||
submitting = false;
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
>
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-6 flex flex-col items-center gap-3 text-center">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-gray-800">
|
||||
<div class="flex size-14 items-center justify-center rounded-full bg-gray-800">
|
||||
<Icon icon={mdiLock} size="28" class="text-gray-400" />
|
||||
</div>
|
||||
{#if surveyTitle}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
let autoNextTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleRadioAnswer(value: string, otherText?: string) {
|
||||
if (!question) return;
|
||||
if (!question) {return;}
|
||||
onAnswer(value, otherText);
|
||||
validationError = null;
|
||||
if (value !== 'Other' || !question.hasOther) {
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
if (!question) return;
|
||||
if (!question) {return;}
|
||||
// Enter/Next can fire inside the 300ms debounce window: flush first and let
|
||||
// the value reach the `answer` prop, or we validate the stale one.
|
||||
preFlush?.flushPending?.();
|
||||
@@ -70,10 +70,10 @@
|
||||
const TAGS_WITH_NATIVE_ENTER_BEHAVIOUR = new Set(['TEXTAREA', 'BUTTON', 'A']);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || e.shiftKey) return;
|
||||
if (e.key !== 'Enter' || e.shiftKey) {return;}
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (TAGS_WITH_NATIVE_ENTER_BEHAVIOUR.has(target.tagName)) return;
|
||||
if (!target) {return;}
|
||||
if (TAGS_WITH_NATIVE_ENTER_BEHAVIOUR.has(target.tagName)) {return;}
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-light fixed bottom-0 left-0 z-40 flex w-full justify-center border-t border-gray-200 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:pt-4 dark:border-gray-800"
|
||||
class="fixed bottom-0 left-0 z-40 flex w-full justify-center border-t border-gray-200 bg-light px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:pt-4 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex w-full max-w-[640px] items-center gap-3">
|
||||
{#if canGoBack}
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!['ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
||||
if (!['ArrowUp', 'ArrowDown'].includes(e.key)) {return;}
|
||||
e.preventDefault();
|
||||
|
||||
const allValues = visibleOptions.map((o) => o.value);
|
||||
if (question.hasOther) allValues.push('Other');
|
||||
if (question.hasOther) {allValues.push('Other');}
|
||||
|
||||
const currentIdx = answer?.value ? allValues.indexOf(answer.value) : -1;
|
||||
let nextIdx: number;
|
||||
@@ -59,7 +59,7 @@
|
||||
aria-checked={answer?.value === option.value}
|
||||
>
|
||||
<span
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full {answer?.value === option.value
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full {answer?.value === option.value
|
||||
? 'bg-immich-primary text-white'
|
||||
: 'border-2 border-gray-300 dark:border-gray-500'}"
|
||||
>
|
||||
@@ -79,7 +79,7 @@
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||
>
|
||||
<span
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full {answer?.value === 'Other'
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full {answer?.value === 'Other'
|
||||
? 'bg-immich-primary text-white'
|
||||
: 'border-2 border-gray-300 dark:border-gray-500'}"
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
{#if loader.error && loader.engine?.currentQuestion}
|
||||
<div class="fixed top-2 left-1/2 z-[100] w-full max-w-lg -translate-x-1/2 px-4">
|
||||
<div class="fixed top-2 left-1/2 z-100 w-full max-w-lg -translate-x-1/2 px-4">
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg bg-red-600 px-4 py-3 text-sm text-white shadow-lg">
|
||||
<p>{loader.error}</p>
|
||||
<button onclick={() => loader.dismissError()} class="shrink-0 font-semibold hover:underline">Dismiss</button>
|
||||
@@ -30,7 +30,7 @@
|
||||
{#if embedded}
|
||||
<p class="text-gray-400">Loading...</p>
|
||||
{:else}
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-400"></div>
|
||||
<div class="size-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-400"></div>
|
||||
<p class="text-sm text-gray-400">Loading survey...</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="text-center">
|
||||
<p class="text-lg text-red-400">{loader.error}</p>
|
||||
{#if showReloadOnError}
|
||||
<button class="mt-4 text-sm text-gray-400 hover:underline" onclick={() => window.location.reload()}>
|
||||
<button class="mt-4 text-sm text-gray-400 hover:underline" onclick={() => location.reload()}>
|
||||
Try again
|
||||
</button>
|
||||
{/if}
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="text-center">
|
||||
<p class="text-gray-400">Something went wrong loading this survey.</p>
|
||||
{#if showReloadOnError}
|
||||
<button class="mt-4 text-sm text-blue-400 hover:underline" onclick={() => window.location.reload()}>
|
||||
<button class="mt-4 text-sm text-blue-400 hover:underline" onclick={() => location.reload()}>
|
||||
Try again
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -27,27 +27,27 @@
|
||||
// Resuming mid-survey: every section up to the current question counts as
|
||||
// already seen, so its header isn't re-shown. A fresh start yields an empty set.
|
||||
function buildInitialSeenSections(): string[] {
|
||||
if (engine.currentIndex === 0 && Object.keys(engine.answers).length === 0) return [];
|
||||
if (engine.currentIndex === 0 && Object.keys(engine.answers).length === 0) {return [];}
|
||||
const currentQ = engine.currentQuestion;
|
||||
if (!currentQ) return [];
|
||||
if (!currentQ) {return [];}
|
||||
const sorted = [...sections].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const currentSection = sorted.find((s) => s.id === currentQ.section_id);
|
||||
if (!currentSection) return [];
|
||||
if (!currentSection) {return [];}
|
||||
return sorted.filter((s) => s.sortOrder <= currentSection.sortOrder).map((s) => s.id);
|
||||
}
|
||||
let seenSectionIds = new SvelteSet<string>(buildInitialSeenSections());
|
||||
|
||||
const reducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const reducedMotion = typeof window !== 'undefined' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const flyDuration = reducedMotion ? 0 : 300;
|
||||
|
||||
const sortedSections = $derived([...sections].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
const currentSectionHeader = $derived.by(() => {
|
||||
const q = engine.currentQuestion;
|
||||
if (!q) return null;
|
||||
if (!q) {return null;}
|
||||
const section = sortedSections.find((s) => s.id === q.section_id);
|
||||
if (!section) return null;
|
||||
if (seenSectionIds.has(section.id) || dismissedSections.has(section.id)) return null;
|
||||
if (!section) {return null;}
|
||||
if (seenSectionIds.has(section.id) || dismissedSections.has(section.id)) {return null;}
|
||||
return section;
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (transitioning) return;
|
||||
if (transitioning) {return;}
|
||||
transitioning = true;
|
||||
direction = 1;
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (transitioning) return;
|
||||
if (transitioning) {return;}
|
||||
transitioning = true;
|
||||
direction = -1;
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
if (showingSectionHeader && backHeader) {
|
||||
dismissedSections.delete(backHeader.id);
|
||||
seenSectionIds.delete(backHeader.id);
|
||||
engine.previous();
|
||||
} else {
|
||||
const currentQ = engine.currentQuestion;
|
||||
if (currentQ) {
|
||||
@@ -105,8 +104,8 @@
|
||||
seenSectionIds.delete(section.id);
|
||||
}
|
||||
}
|
||||
engine.previous();
|
||||
}
|
||||
engine.previous();
|
||||
|
||||
announce(`Question ${engine.currentIndex + 1} of ${engine.totalQuestions}`);
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user