diff --git a/apps/survey.immich.app/backend/scripts/load-test.ts b/apps/survey.immich.app/backend/scripts/load-test.ts index 0203ac48..75b061b6 100644 --- a/apps/survey.immich.app/backend/scripts/load-test.ts +++ b/apps/survey.immich.app/backend/scripts/load-test.ts @@ -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(); 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 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[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 => { - 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 => { - 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); }); diff --git a/apps/survey.immich.app/backend/scripts/memory-profile.ts b/apps/survey.immich.app/backend/scripts/memory-profile.ts index 4ba81b3b..4e51e6cd 100644 --- a/apps/survey.immich.app/backend/scripts/memory-profile.ts +++ b/apps/survey.immich.app/backend/scripts/memory-profile.ts @@ -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(); 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(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'); } diff --git a/apps/survey.immich.app/backend/src/constants.ts b/apps/survey.immich.app/backend/src/constants.ts index 62c66b0c..c2c41773 100644 --- a/apps/survey.immich.app/backend/src/constants.ts +++ b/apps/survey.immich.app/backend/src/constants.ts @@ -38,9 +38,11 @@ export const ROLE_HIERARCHY: Record = { 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]; } diff --git a/apps/survey.immich.app/backend/src/cookie.ts b/apps/survey.immich.app/backend/src/cookie.ts index 08434c3f..472d0d84 100644 --- a/apps/survey.immich.app/backend/src/cookie.ts +++ b/apps/survey.immich.app/backend/src/cookie.ts @@ -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]; } diff --git a/apps/survey.immich.app/backend/src/db.ts b/apps/survey.immich.app/backend/src/db.ts index 806783e1..d082a9d8 100644 --- a/apps/survey.immich.app/backend/src/db.ts +++ b/apps/survey.immich.app/backend/src/db.ts @@ -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'; } diff --git a/apps/survey.immich.app/backend/src/durable-objects/cache.ts b/apps/survey.immich.app/backend/src/durable-objects/cache.ts index 76801b68..4ef9595c 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/cache.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/cache.ts @@ -43,16 +43,20 @@ export class SurveyCache { */ private _respondentState = new Map(); - /** 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 { - 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 { - 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) { diff --git a/apps/survey.immich.app/backend/src/durable-objects/do-commands.ts b/apps/survey.immich.app/backend/src/durable-objects/do-commands.ts index e4000897..8e066553 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/do-commands.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/do-commands.ts @@ -71,8 +71,9 @@ export async function execute(op: string, data: Record, 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, 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, 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, ctx: Co }); } - case 'export-definition': + case 'export-definition': { return ctx.surveyService.exportDefinition(surveyId); + } - default: + default: { return null; + } } } diff --git a/apps/survey.immich.app/backend/src/durable-objects/schema.ts b/apps/survey.immich.app/backend/src/durable-objects/schema.ts index 8afd968f..e217af30 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/schema.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/schema.ts @@ -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(); -/** 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); } diff --git a/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts b/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts index 189af2b5..2ec49cc4 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/survey-do.ts @@ -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 { - 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; 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); } diff --git a/apps/survey.immich.app/backend/src/durable-objects/ws/ws-broadcaster.ts b/apps/survey.immich.app/backend/src/durable-objects/ws/ws-broadcaster.ts index 61cd2613..62072acf 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/ws/ws-broadcaster.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/ws/ws-broadcaster.ts @@ -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 { 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'), diff --git a/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts b/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts index c60b9c03..d65ffd95 100644 --- a/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts +++ b/apps/survey.immich.app/backend/src/durable-objects/ws/ws-handler.ts @@ -71,7 +71,7 @@ const OP_ROLES: Record = { 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 ?? {}; } } diff --git a/apps/survey.immich.app/backend/src/index.ts b/apps/survey.immich.app/backend/src/index.ts index 0ad03003..97f71a73 100644 --- a/apps/survey.immich.app/backend/src/index.ts +++ b/apps/survey.immich.app/backend/src/index.ts @@ -40,9 +40,9 @@ function registerAllRoutes(router: ReturnType) { // 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(); 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 { const syncHeader = response.headers.get('X-Catalog-Sync'); - if (!syncHeader) return; + if (!syncHeader) {return;} try { const fields = JSON.parse(syncHeader) as Record; 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'; } diff --git a/apps/survey.immich.app/backend/src/middleware/auth.ts b/apps/survey.immich.app/backend/src/middleware/auth.ts index ca96419f..2cfabd78 100644 --- a/apps/survey.immich.app/backend/src/middleware/auth.ts +++ b/apps/survey.immich.app/backend/src/middleware/auth.ts @@ -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); diff --git a/apps/survey.immich.app/backend/src/migrator.ts b/apps/survey.immich.app/backend/src/migrator.ts index 4a669275..7b24592e 100644 --- a/apps/survey.immich.app/backend/src/migrator.ts +++ b/apps/survey.immich.app/backend/src/migrator.ts @@ -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; } diff --git a/apps/survey.immich.app/backend/src/repositories/respondent.repository.ts b/apps/survey.immich.app/backend/src/repositories/respondent.repository.ts index 4402b5e0..864f4527 100644 --- a/apps/survey.immich.app/backend/src/repositories/respondent.repository.ts +++ b/apps/survey.immich.app/backend/src/repositories/respondent.repository.ts @@ -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 { - 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 diff --git a/apps/survey.immich.app/backend/src/repositories/survey.repository.ts b/apps/survey.immich.app/backend/src/repositories/survey.repository.ts index 762f554d..c45cbcf4 100644 --- a/apps/survey.immich.app/backend/src/repositories/survey.repository.ts +++ b/apps/survey.immich.app/backend/src/repositories/survey.repository.ts @@ -52,7 +52,7 @@ export class SurveyRepository { } async update(id: string, fields: Partial>): Promise { - 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>): Promise { - 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>): Promise { - 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(); } diff --git a/apps/survey.immich.app/backend/src/repositories/tag.repository.ts b/apps/survey.immich.app/backend/src/repositories/tag.repository.ts index e9b89fa4..98eaf904 100644 --- a/apps/survey.immich.app/backend/src/repositories/tag.repository.ts +++ b/apps/survey.immich.app/backend/src/repositories/tag.repository.ts @@ -20,7 +20,7 @@ export class TagRepository { } async update(id: string, fields: Partial>): Promise { - 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(); } diff --git a/apps/survey.immich.app/backend/src/routes/auth.ts b/apps/survey.immich.app/backend/src/routes/auth.ts index 2ae7b3c7..72dd1db8 100644 --- a/apps/survey.immich.app/backend/src/routes/auth.ts +++ b/apps/survey.immich.app/backend/src/routes/auth.ts @@ -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); diff --git a/apps/survey.immich.app/backend/src/routes/respondents.ts b/apps/survey.immich.app/backend/src/routes/respondents.ts index 62fbed38..4141d045 100644 --- a/apps/survey.immich.app/backend/src/routes/respondents.ts +++ b/apps/survey.immich.app/backend/src/routes/respondents.ts @@ -14,7 +14,7 @@ async function validatePasswordSession( survey: SurveyRow, ctx: AppContext, ): Promise { - 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))) { diff --git a/apps/survey.immich.app/backend/src/routing.ts b/apps/survey.immich.app/backend/src/routing.ts index 74f0c01f..b687297e 100644 --- a/apps/survey.immich.app/backend/src/routing.ts +++ b/apps/survey.immich.app/backend/src/routing.ts @@ -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\/([^/]+)(\/.*)?$/; diff --git a/apps/survey.immich.app/backend/src/server.ts b/apps/survey.immich.app/backend/src/server.ts index 0d74d8f1..814edd17 100644 --- a/apps/survey.immich.app/backend/src/server.ts +++ b/apps/survey.immich.app/backend/src/server.ts @@ -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); }); diff --git a/apps/survey.immich.app/backend/src/services/auth.service.ts b/apps/survey.immich.app/backend/src/services/auth.service.ts index 35c39585..438c238c 100644 --- a/apps/survey.immich.app/backend/src/services/auth.service.ts +++ b/apps/survey.immich.app/backend/src/services/auth.service.ts @@ -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 }; cachedJwks = { data, fetchedAt: Date.now() }; return data; @@ -141,11 +141,11 @@ export class AuthService { private async fetchUserInfo(accessToken: string): Promise | 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; } catch { return null; @@ -154,9 +154,9 @@ export class AuthService { async validateIdToken(idToken: string, nonce: string, accessToken?: string): Promise { 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; + const payload = JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))) as Record; - 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 = 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}`; diff --git a/apps/survey.immich.app/backend/src/services/backup.service.ts b/apps/survey.immich.app/backend/src/services/backup.service.ts index 82bcd974..4f5b3ddd 100644 --- a/apps/survey.immich.app/backend/src/services/backup.service.ts +++ b/apps/survey.immich.app/backend/src/services/backup.service.ts @@ -68,7 +68,7 @@ function validateRow(table: string, row: unknown, index: number): Record; - 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}`); } } diff --git a/apps/survey.immich.app/backend/src/services/in-memory-presence.ts b/apps/survey.immich.app/backend/src/services/in-memory-presence.ts index 947504c5..0c5be190 100644 --- a/apps/survey.immich.app/backend/src/services/in-memory-presence.ts +++ b/apps/survey.immich.app/backend/src/services/in-memory-presence.ts @@ -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 { diff --git a/apps/survey.immich.app/backend/src/services/respondent.service.ts b/apps/survey.immich.app/backend/src/services/respondent.service.ts index db6795fc..efe36935 100644 --- a/apps/survey.immich.app/backend/src/services/respondent.service.ts +++ b/apps/survey.immich.app/backend/src/services/respondent.service.ts @@ -44,11 +44,13 @@ export class RespondentService { } private async checkResponseLimit(survey: SurveyRow): Promise { - 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({ @@ -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> { 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(); 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 { diff --git a/apps/survey.immich.app/backend/src/services/survey.service.ts b/apps/survey.immich.app/backend/src/services/survey.service.ts index 0fc1f07b..93475c1a 100644 --- a/apps/survey.immich.app/backend/src/services/survey.service.ts +++ b/apps/survey.immich.app/backend/src/services/survey.service.ts @@ -182,7 +182,7 @@ export class SurveyService { async updateSurvey(id: string, input: UpdateSurveyInput, existing?: SurveyRow): Promise { 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 = { 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 { 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 { - 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 { 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 { 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 = {}; @@ -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 { 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 { 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 = {}; @@ -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 { 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 { 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 { - if (!details) details = await this.getSurvey(id); + if (!details) {details = await this.getSurvey(id);} const { survey, sections, questions } = details; return { version: 1, diff --git a/apps/survey.immich.app/backend/src/services/tag.service.ts b/apps/survey.immich.app/backend/src/services/tag.service.ts index 25fd8ed1..6a05edb7 100644 --- a/apps/survey.immich.app/backend/src/services/tag.service.ts +++ b/apps/survey.immich.app/backend/src/services/tag.service.ts @@ -9,7 +9,7 @@ export class TagService { } async createTag(input: { name: string; color?: string }): Promise { - 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 { 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> = {}; 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 { 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); } diff --git a/apps/survey.immich.app/backend/src/utils/crypto.ts b/apps/survey.immich.app/backend/src/utils/crypto.ts index 29e1f592..ed672747 100644 --- a/apps/survey.immich.app/backend/src/utils/crypto.ts +++ b/apps/survey.immich.app/backend/src/utils/crypto.ts @@ -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 { - 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 { } export async function verifyPassword(password: string, stored: string): Promise { - 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 { - 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); } diff --git a/apps/survey.immich.app/backend/src/utils/session.ts b/apps/survey.immich.app/backend/src/utils/session.ts index 6083ee79..1c0fd50a 100644 --- a/apps/survey.immich.app/backend/src/utils/session.ts +++ b/apps/survey.immich.app/backend/src/utils/session.ts @@ -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 { 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; + const payload = JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))) as Record; - 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, diff --git a/apps/survey.immich.app/backend/src/utils/survey-password-token.ts b/apps/survey.immich.app/backend/src/utils/survey-password-token.ts index 109b2a08..723c34ed 100644 --- a/apps/survey.immich.app/backend/src/utils/survey-password-token.ts +++ b/apps/survey.immich.app/backend/src/utils/survey-password-token.ts @@ -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 }; } diff --git a/apps/survey.immich.app/backend/test/helpers.ts b/apps/survey.immich.app/backend/test/helpers.ts index 7e4e19fe..8482b5de 100644 --- a/apps/survey.immich.app/backend/test/helpers.ts +++ b/apps/survey.immich.app/backend/test/helpers.ts @@ -29,7 +29,7 @@ export async function authedRequest(path: string, options?: RequestInit): Promis } export async function getAdminCookie(): Promise { - 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 { }); 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 { }); 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}`; diff --git a/apps/survey.immich.app/backend/test/respondents.integration.ts b/apps/survey.immich.app/backend/test/respondents.integration.ts index 8353f75f..7e41b1b2 100644 --- a/apps/survey.immich.app/backend/test/respondents.integration.ts +++ b/apps/survey.immich.app/backend/test/respondents.integration.ts @@ -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[] }; diff --git a/apps/survey.immich.app/backend/test/results.integration.ts b/apps/survey.immich.app/backend/test/results.integration.ts index b1f00a61..ace4458d 100644 --- a/apps/survey.immich.app/backend/test/results.integration.ts +++ b/apps/survey.immich.app/backend/test/results.integration.ts @@ -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', diff --git a/apps/survey.immich.app/backend/test/validation.integration.ts b/apps/survey.immich.app/backend/test/validation.integration.ts index f5d7be71..7efa2689 100644 --- a/apps/survey.immich.app/backend/test/validation.integration.ts +++ b/apps/survey.immich.app/backend/test/validation.integration.ts @@ -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); }); diff --git a/apps/survey.immich.app/backend/tsconfig.json b/apps/survey.immich.app/backend/tsconfig.json index 29cd09d5..e2b51f79 100644 --- a/apps/survey.immich.app/backend/tsconfig.json +++ b/apps/survey.immich.app/backend/tsconfig.json @@ -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"] } diff --git a/apps/survey.immich.app/backend/vitest.config.mts b/apps/survey.immich.app/backend/vitest.config.mts index f64267da..1253fb75 100644 --- a/apps/survey.immich.app/backend/vitest.config.mts +++ b/apps/survey.immich.app/backend/vitest.config.mts @@ -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, }, diff --git a/apps/survey.immich.app/e2e/auth.e2e.ts b/apps/survey.immich.app/e2e/auth.e2e.ts index 4244029a..34f6f909 100644 --- a/apps/survey.immich.app/e2e/auth.e2e.ts +++ b/apps/survey.immich.app/e2e/auth.e2e.ts @@ -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); } }); diff --git a/apps/survey.immich.app/e2e/helpers.ts b/apps/survey.immich.app/e2e/helpers.ts index 269a5621..1548294b 100644 --- a/apps/survey.immich.app/e2e/helpers.ts +++ b/apps/survey.immich.app/e2e/helpers.ts @@ -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 { - 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 { }); 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 { }); 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(); } diff --git a/apps/survey.immich.app/e2e/oidc-server.ts b/apps/survey.immich.app/e2e/oidc-server.ts index 3f3a8e34..193c57a7 100644 --- a/apps/survey.immich.app/e2e/oidc-server.ts +++ b/apps/survey.immich.app/e2e/oidc-server.ts @@ -44,7 +44,7 @@ const TEST_USERS: Record = { 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 { } // 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')); } diff --git a/apps/survey.immich.app/e2e/survey-builder.e2e.ts b/apps/survey.immich.app/e2e/survey-builder.e2e.ts index 69a54c13..6745d1fa 100644 --- a/apps/survey.immich.app/e2e/survey-builder.e2e.ts +++ b/apps/survey.immich.app/e2e/survey-builder.e2e.ts @@ -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'); diff --git a/apps/survey.immich.app/e2e/survey-do.e2e.ts b/apps/survey.immich.app/e2e/survey-do.e2e.ts index 95b9d1ea..0557ab43 100644 --- a/apps/survey.immich.app/e2e/survey-do.e2e.ts +++ b/apps/survey.immich.app/e2e/survey-do.e2e.ts @@ -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 = {}; - 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(() => { diff --git a/apps/survey.immich.app/e2e/survey-flow.e2e.ts b/apps/survey.immich.app/e2e/survey-flow.e2e.ts index a59acf6e..76efbc6a 100644 --- a/apps/survey.immich.app/e2e/survey-flow.e2e.ts +++ b/apps/survey.immich.app/e2e/survey-flow.e2e.ts @@ -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); diff --git a/apps/survey.immich.app/e2e/survey-limits.e2e.ts b/apps/survey.immich.app/e2e/survey-limits.e2e.ts index 390dd096..273f8b25 100644 --- a/apps/survey.immich.app/e2e/survey-limits.e2e.ts +++ b/apps/survey.immich.app/e2e/survey-limits.e2e.ts @@ -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, }); }); }); diff --git a/apps/survey.immich.app/e2e/survey-results.e2e.ts b/apps/survey.immich.app/e2e/survey-results.e2e.ts index d7c103d6..a602a1ae 100644 --- a/apps/survey.immich.app/e2e/survey-results.e2e.ts +++ b/apps/survey.immich.app/e2e/survey-results.e2e.ts @@ -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(); diff --git a/apps/survey.immich.app/eslint.config.js b/apps/survey.immich.app/eslint.config.js deleted file mode 100644 index 069228aa..00000000 --- a/apps/survey.immich.app/eslint.config.js +++ /dev/null @@ -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/'], - }, -]; diff --git a/apps/survey.immich.app/playwright.config.ts b/apps/survey.immich.app/playwright.config.ts index 3efe0902..603665ce 100644 --- a/apps/survey.immich.app/playwright.config.ts +++ b/apps/survey.immich.app/playwright.config.ts @@ -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. diff --git a/apps/survey.immich.app/shared/answer-validation.ts b/apps/survey.immich.app/shared/answer-validation.ts index 1b36d6b4..d098d812 100644 --- a/apps/survey.immich.app/shared/answer-validation.ts +++ b/apps/survey.immich.app/shared/answer-validation.ts @@ -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): 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}`; diff --git a/apps/survey.immich.app/shared/ws-protocol.ts b/apps/survey.immich.app/shared/ws-protocol.ts index 3ab6983c..414bd07f 100644 --- a/apps/survey.immich.app/shared/ws-protocol.ts +++ b/apps/survey.immich.app/shared/ws-protocol.ts @@ -88,7 +88,9 @@ export interface CreateQuestionInput { config?: Record; } -/** 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'; diff --git a/apps/survey.immich.app/src/lib/answer-validation.test.ts b/apps/survey.immich.app/src/lib/answer-validation.test.ts index 70c6b787..df2ca176 100644 --- a/apps/survey.immich.app/src/lib/answer-validation.test.ts +++ b/apps/survey.immich.app/src/lib/answer-validation.test.ts @@ -12,7 +12,7 @@ function q(overrides: Partial & { 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', ); }); diff --git a/apps/survey.immich.app/src/lib/api/auth.ts b/apps/survey.immich.app/src/lib/api/auth.ts index e44c19fd..6bb37561 100644 --- a/apps/survey.immich.app/src/lib/api/auth.ts +++ b/apps/survey.immich.app/src/lib/api/auth.ts @@ -17,7 +17,7 @@ interface AuthState { export async function getMe(): Promise { 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; } catch { return { authenticated: false }; @@ -29,7 +29,7 @@ export async function setup(password: string, setupToken?: string): Promise { } 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 { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); - window.location.href = '/'; + location.assign('/'); } diff --git a/apps/survey.immich.app/src/lib/api/client.ts b/apps/survey.immich.app/src/lib/api/client.ts index 3a89c990..3992745e 100644 --- a/apps/survey.immich.app/src/lib/api/client.ts +++ b/apps/survey.immich.app/src/lib/api/client.ts @@ -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 { - 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 { diff --git a/apps/survey.immich.app/src/lib/api/request.ts b/apps/survey.immich.app/src/lib/api/request.ts index c44d6ff7..091475c8 100644 --- a/apps/survey.immich.app/src/lib/api/request.ts +++ b/apps/survey.immich.app/src/lib/api/request.ts @@ -11,13 +11,13 @@ export async function request(url: string, options?: RequestInit): Promise 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; } diff --git a/apps/survey.immich.app/src/lib/api/survey-admin.ts b/apps/survey.immich.app/src/lib/api/survey-admin.ts index e45445d0..ed9156cd 100644 --- a/apps/survey.immich.app/src/lib/api/survey-admin.ts +++ b/apps/survey.immich.app/src/lib/api/survey-admin.ts @@ -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>; total: number }>(`/api/surveys?${params}`); return { surveys: data.surveys.map(surveyFromApi), total: data.total }; } diff --git a/apps/survey.immich.app/src/lib/api/survey-results.ts b/apps/survey.immich.app/src/lib/api/survey-results.ts index 45988165..fc840a65 100644 --- a/apps/survey.immich.app/src/lib/api/survey-results.ts +++ b/apps/survey.immich.app/src/lib/api/survey-results.ts @@ -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 { 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 { 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'); diff --git a/apps/survey.immich.app/src/lib/api/survey-ws.ts b/apps/survey.immich.app/src/lib/api/survey-ws.ts index d281ed99..f322c08a 100644 --- a/apps/survey.immich.app/src/lib/api/survey-ws.ts +++ b/apps/survey.immich.app/src/lib/api/survey-ws.ts @@ -5,7 +5,9 @@ export interface SurveyWsClient { on(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 | 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 void>>(); const pendingRequests = new Map 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(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(); // key: `${slug}:${type}` const connectionsBySurveyId = new Map(); -/** 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; diff --git a/apps/survey.immich.app/src/lib/components/auth/LoginScreen.svelte b/apps/survey.immich.app/src/lib/components/auth/LoginScreen.svelte index fd04de7c..7d684d8b 100644 --- a/apps/survey.immich.app/src/lib/components/auth/LoginScreen.svelte +++ b/apps/survey.immich.app/src/lib/components/auth/LoginScreen.svelte @@ -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 @@
-
+

Sign in to FUTO Surveys

@@ -73,7 +73,7 @@
{/if} - diff --git a/apps/survey.immich.app/src/lib/components/auth/SetupScreen.svelte b/apps/survey.immich.app/src/lib/components/auth/SetupScreen.svelte index fd46b544..81278fef 100644 --- a/apps/survey.immich.app/src/lib/components/auth/SetupScreen.svelte +++ b/apps/survey.immich.app/src/lib/components/auth/SetupScreen.svelte @@ -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 @@
-
+

Welcome to FUTO Surveys

diff --git a/apps/survey.immich.app/src/lib/components/builder/BulkPasteModal.svelte b/apps/survey.immich.app/src/lib/components/builder/BulkPasteModal.svelte index 0275d103..e29109c5 100644 --- a/apps/survey.immich.app/src/lib/components/builder/BulkPasteModal.svelte +++ b/apps/survey.immich.app/src/lib/components/builder/BulkPasteModal.svelte @@ -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 }))); } @@ -54,7 +54,7 @@
- {lineCount} option{lineCount !== 1 ? 's' : ''} + {lineCount} option{lineCount === 1 ? '' : 's'}
diff --git a/apps/survey.immich.app/src/lib/components/builder/OptionListEditor.svelte b/apps/survey.immich.app/src/lib/components/builder/OptionListEditor.svelte index f4418952..b4c1cef5 100644 --- a/apps/survey.immich.app/src/lib/components/builder/OptionListEditor.svelte +++ b/apps/survey.immich.app/src/lib/components/builder/OptionListEditor.svelte @@ -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" >
{#if expanded} -
+
Question {index + 1}
{:else}
Section {index + 1} - {section.questions.length} question{section.questions.length !== 1 ? 's' : ''} + {section.questions.length} question{section.questions.length === 1 ? '' : 's'}
diff --git a/apps/survey.immich.app/src/lib/components/layout/UserMenu.svelte b/apps/survey.immich.app/src/lib/components/layout/UserMenu.svelte index f3322332..914f07cd 100644 --- a/apps/survey.immich.app/src/lib/components/layout/UserMenu.svelte +++ b/apps/survey.immich.app/src/lib/components/layout/UserMenu.svelte @@ -25,7 +25,7 @@ {auth.user.name || auth.user.email} {#if error} {error} {/if} diff --git a/apps/survey.immich.app/src/lib/components/results/QuestionResult.svelte b/apps/survey.immich.app/src/lib/components/results/QuestionResult.svelte index 52fcc4b8..7539d939 100644 --- a/apps/survey.immich.app/src/lib/components/results/QuestionResult.svelte +++ b/apps/survey.immich.app/src/lib/components/results/QuestionResult.svelte @@ -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 @@
-

{question.text}

+

{question.text}

diff --git a/apps/survey.immich.app/src/lib/components/results/QuestionTimingChart.svelte b/apps/survey.immich.app/src/lib/components/results/QuestionTimingChart.svelte index 0b5e135e..6b260d8a 100644 --- a/apps/survey.immich.app/src/lib/components/results/QuestionTimingChart.svelte +++ b/apps/survey.immich.app/src/lib/components/results/QuestionTimingChart.svelte @@ -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)}
-
+
{#if row.sampleSize > 0} {@const p5 = xPct(row.p5Ms)} {@const p25 = xPct(row.p25Ms)} @@ -96,7 +96,7 @@
-
+
{#each ticks as t (t.ms)}
diff --git a/apps/survey.immich.app/src/lib/components/results/RatingResult.svelte b/apps/survey.immich.app/src/lib/components/results/RatingResult.svelte index 02048d2a..133de643 100644 --- a/apps/survey.immich.app/src/lib/components/results/RatingResult.svelte +++ b/apps/survey.immich.app/src/lib/components/results/RatingResult.svelte @@ -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 @@
Average
- {stats.mean !== null ? stats.mean.toFixed(1) : '–'} + {stats.mean === null ? '–' : stats.mean.toFixed(1)} / {scaleMax}
diff --git a/apps/survey.immich.app/src/lib/components/results/ResponseViewer.svelte b/apps/survey.immich.app/src/lib/components/results/ResponseViewer.svelte index b66733b5..83a05500 100644 --- a/apps/survey.immich.app/src/lib/components/results/ResponseViewer.svelte +++ b/apps/survey.immich.app/src/lib/components/results/ResponseViewer.svelte @@ -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); diff --git a/apps/survey.immich.app/src/lib/components/results/StatStrip.svelte b/apps/survey.immich.app/src/lib/components/results/StatStrip.svelte index 64f286b7..2ea6be19 100644 --- a/apps/survey.immich.app/src/lib/components/results/StatStrip.svelte +++ b/apps/survey.immich.app/src/lib/components/results/StatStrip.svelte @@ -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'; + } } } diff --git a/apps/survey.immich.app/src/lib/components/results/TextResult.svelte b/apps/survey.immich.app/src/lib/components/results/TextResult.svelte index aa98e6f2..cbce327c 100644 --- a/apps/survey.immich.app/src/lib/components/results/TextResult.svelte +++ b/apps/survey.immich.app/src/lib/components/results/TextResult.svelte @@ -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(() => { const counts: Record = {}; 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); diff --git a/apps/survey.immich.app/src/lib/components/results/TextSearch.svelte b/apps/survey.immich.app/src/lib/components/results/TextSearch.svelte index 9dc392a3..4f4a490b 100644 --- a/apps/survey.immich.app/src/lib/components/results/TextSearch.svelte +++ b/apps/survey.immich.app/src/lib/components/results/TextSearch.svelte @@ -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 @@ {#if expanded} -
+
{#if loadingDetail}

Loading full response…

{:else if detailError} diff --git a/apps/survey.immich.app/src/lib/components/results/TimelineChart.svelte b/apps/survey.immich.app/src/lib/components/results/TimelineChart.svelte index 89c7a7e1..a56127ef 100644 --- a/apps/survey.immich.app/src/lib/components/results/TimelineChart.svelte +++ b/apps/survey.immich.app/src/lib/components/results/TimelineChart.svelte @@ -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 = {}; - 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)'; diff --git a/apps/survey.immich.app/src/lib/components/results/analytics-utils.test.ts b/apps/survey.immich.app/src/lib/components/results/analytics-utils.test.ts index c5e17846..dd6d4007 100644 --- a/apps/survey.immich.app/src/lib/components/results/analytics-utils.test.ts +++ b/apps/survey.immich.app/src/lib/components/results/analytics-utils.test.ts @@ -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); }); diff --git a/apps/survey.immich.app/src/lib/components/results/analytics-utils.ts b/apps/survey.immich.app/src/lib/components/results/analytics-utils.ts index cfbc55ee..2797f05f 100644 --- a/apps/survey.immich.app/src/lib/components/results/analytics-utils.ts +++ b/apps/survey.immich.app/src/lib/components/results/analytics-utils.ts @@ -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 = {}; - 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(); @@ -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); } diff --git a/apps/survey.immich.app/src/lib/components/results/chart-utils.ts b/apps/survey.immich.app/src/lib/components/results/chart-utils.ts index a903492e..ba7aded3 100644 --- a/apps/survey.immich.app/src/lib/components/results/chart-utils.ts +++ b/apps/survey.immich.app/src/lib/components/results/chart-utils.ts @@ -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)', diff --git a/apps/survey.immich.app/src/lib/components/sharing/SharePanel.svelte b/apps/survey.immich.app/src/lib/components/sharing/SharePanel.svelte index 5501ddd2..6e51adc0 100644 --- a/apps/survey.immich.app/src/lib/components/sharing/SharePanel.svelte +++ b/apps/survey.immich.app/src/lib/components/sharing/SharePanel.svelte @@ -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" > @@ -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" > @@ -30,7 +30,7 @@ {#if embedded}

Loading...

{:else} -
+

Loading survey...

{/if}
@@ -41,7 +41,7 @@

{loader.error}

{#if showReloadOnError} - {/if} @@ -69,7 +69,7 @@

Something went wrong loading this survey.

{#if showReloadOnError} - {/if} diff --git a/apps/survey.immich.app/src/lib/components/survey/SurveyShell.svelte b/apps/survey.immich.app/src/lib/components/survey/SurveyShell.svelte index 7059313d..f187a5e6 100644 --- a/apps/survey.immich.app/src/lib/components/survey/SurveyShell.svelte +++ b/apps/survey.immich.app/src/lib/components/survey/SurveyShell.svelte @@ -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(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); diff --git a/apps/survey.immich.app/src/lib/components/survey/WelcomeScreen.svelte b/apps/survey.immich.app/src/lib/components/survey/WelcomeScreen.svelte index 0a7e07c6..8850d9e1 100644 --- a/apps/survey.immich.app/src/lib/components/survey/WelcomeScreen.svelte +++ b/apps/survey.immich.app/src/lib/components/survey/WelcomeScreen.svelte @@ -22,13 +22,13 @@

{survey.welcomeTitle ?? survey.title}

{#if survey.welcomeDescription} -

{survey.welcomeDescription}

+

{survey.welcomeDescription}

{:else if survey.description} -

{survey.description}

+

{survey.description}

{/if} {#if sortedSections.length > 1} -
+

{sortedSections.length} sections

@@ -36,7 +36,7 @@ {#each sortedSections as section, i (section.id)}
  • {i + 1} diff --git a/apps/survey.immich.app/src/lib/components/survey/use-debounced-answer.ts b/apps/survey.immich.app/src/lib/components/survey/use-debounced-answer.ts index e46f1d5a..7a857f18 100644 --- a/apps/survey.immich.app/src/lib/components/survey/use-debounced-answer.ts +++ b/apps/survey.immich.app/src/lib/components/survey/use-debounced-answer.ts @@ -20,11 +20,13 @@ export function useDebouncedAnswer( >('survey-pre-flush'); function flush() { - if (debounceTimer !== undefined) { - clearTimeout(debounceTimer); - debounceTimer = undefined; - onAnswer(getLatestValue()); + if (debounceTimer === undefined) { + return; } + + clearTimeout(debounceTimer); + debounceTimer = undefined; + onAnswer(getLatestValue()); } function handleInput() { diff --git a/apps/survey.immich.app/src/lib/engines/admin.test.ts b/apps/survey.immich.app/src/lib/engines/admin.test.ts index 74630e00..b9c8ed70 100644 --- a/apps/survey.immich.app/src/lib/engines/admin.test.ts +++ b/apps/survey.immich.app/src/lib/engines/admin.test.ts @@ -74,7 +74,7 @@ function createExportDefinition( } function validateImportDefinition(def: unknown): { valid: boolean; error?: string } { - if (!def || typeof def !== 'object') return { valid: false, error: 'Invalid definition' }; + if (!def || typeof def !== 'object') {return { valid: false, error: 'Invalid definition' };} const d = def as Record; if (!d.title || typeof d.title !== 'string' || !d.title.trim()) { return { valid: false, error: 'Title is required' }; @@ -201,7 +201,7 @@ describe('Tag data', () => { }); it('whitespace-only tag name should be invalid', () => { - const name = ' '; + const name = ' '.repeat(3); expect(name.trim().length).toBe(0); }); @@ -297,7 +297,7 @@ describe('Import/export definition', () => { }); it('import with whitespace-only title should fail', () => { - const result = validateImportDefinition({ title: ' ', sections: [{ title: 'S' }] }); + const result = validateImportDefinition({ title: ' '.repeat(3), sections: [{ title: 'S' }] }); expect(result.valid).toBe(false); }); diff --git a/apps/survey.immich.app/src/lib/engines/analytics.test.ts b/apps/survey.immich.app/src/lib/engines/analytics.test.ts index fd782de7..81bccd48 100644 --- a/apps/survey.immich.app/src/lib/engines/analytics.test.ts +++ b/apps/survey.immich.app/src/lib/engines/analytics.test.ts @@ -47,10 +47,10 @@ function toWordCloudData(answers: ChartAnswer[]) { // Replica of the filterable-type list in FilterBar. -const FILTERABLE_TYPES: QuestionType[] = ['radio', 'checkbox', 'dropdown', 'nps', 'rating', 'likert']; +const FILTERABLE_TYPES: Set = new Set(['radio', 'checkbox', 'dropdown', 'nps', 'rating', 'likert']); function filterableQuestions(questions: SurveyQuestion[]): SurveyQuestion[] { - return questions.filter((q) => FILTERABLE_TYPES.includes(q.type)); + return questions.filter((q) => FILTERABLE_TYPES.has(q.type)); } describe('Type shape validation', () => { diff --git a/apps/survey.immich.app/src/lib/engines/auth.test.ts b/apps/survey.immich.app/src/lib/engines/auth.test.ts index a0ccefa1..609f8b54 100644 --- a/apps/survey.immich.app/src/lib/engines/auth.test.ts +++ b/apps/survey.immich.app/src/lib/engines/auth.test.ts @@ -18,7 +18,7 @@ const ROLE_HIERARCHY: Record = { admin: 3, editor: 2, viewer: * Mirrors `requireRole` from backend/src/middleware/auth.ts */ function requireRole(user: UserInfo | undefined, minRole: UserRole): void { - if (!user) throw new Error('Authentication required'); + if (!user) {throw new Error('Authentication required');} if (ROLE_HIERARCHY[user.role] < ROLE_HIERARCHY[minRole]) { throw new Error('Insufficient permissions'); } @@ -44,14 +44,14 @@ function extractRole( } if (Array.isArray(value)) { - if (value.includes(adminValue)) return 'admin'; - if (value.includes(editorValue)) return 'editor'; + if (value.includes(adminValue)) {return 'admin';} + if (value.includes(editorValue)) {return 'editor';} return 'viewer'; } if (typeof value === 'string') { - if (value === adminValue) return 'admin'; - if (value === editorValue) return 'editor'; + if (value === adminValue) {return 'admin';} + if (value === editorValue) {return 'editor';} } return 'viewer'; @@ -61,16 +61,16 @@ function extractRole( * Mirrors `getAuth().hasRole()` from stores/auth.svelte.ts */ function hasRole(user: AuthUser | null, minRole: UserRole): boolean { - if (!user) return false; + if (!user) {return false;} return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[minRole]; } function isValidJwtStructure(token: string): boolean { const parts = token.split('.'); - if (parts.length !== 3) return false; + if (parts.length !== 3) {return false;} try { for (const part of parts.slice(0, 2)) { - JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))); + JSON.parse(atob(part.replaceAll('-', '+').replaceAll('_', '/'))); } return true; } catch { @@ -91,9 +91,9 @@ function createTestJwt(payload: Record): string { function decodeJwtPayload(token: string): Record | null { const parts = token.split('.'); - if (parts.length !== 3) return null; + if (parts.length !== 3) {return null;} try { - return JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + return JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))); } catch { return null; } @@ -339,7 +339,7 @@ describe('Session JWT structure', () => { it('JWT header contains alg and typ', () => { const token = createTestJwt({ sub: 'u1' }); - const headerPart = token.split('.')[0]; + const headerPart = token.split('.', 1)[0]; const header = JSON.parse(atob(headerPart)); expect(header.alg).toBe('HS256'); expect(header.typ).toBe('JWT'); diff --git a/apps/survey.immich.app/src/lib/engines/builder-factories.ts b/apps/survey.immich.app/src/lib/engines/builder-factories.ts index fdc61f5a..477ee28f 100644 --- a/apps/survey.immich.app/src/lib/engines/builder-factories.ts +++ b/apps/survey.immich.app/src/lib/engines/builder-factories.ts @@ -38,28 +38,34 @@ export function createQuestionOfType(type: QuestionType, sortOrder: number): Bui switch (type) { case 'radio': case 'checkbox': - case 'dropdown': + case 'dropdown': { // keep the default options from createDefaultQuestion break; - case 'rating': + } + case 'rating': { base.options = []; base.config = { scaleMax: 5 }; break; - case 'nps': + } + case 'nps': { base.options = []; base.config = { scaleMax: 10 }; break; - case 'number': + } + case 'number': { base.options = []; base.config = { min: 0, max: 100 }; break; - case 'likert': + } + case 'likert': { base.options = []; base.config = { scaleMax: 5 }; break; - default: + } + default: { base.options = []; break; + } } return base; @@ -70,7 +76,7 @@ export function duplicateQuestion(question: BuilderQuestion, sortOrder: number): ...question, id: '', sortOrder, - options: [...question.options.map((o) => ({ ...o }))], + options: question.options.map((o) => ({ ...o })), config: { ...question.config }, }; } diff --git a/apps/survey.immich.app/src/lib/engines/builder-save.ts b/apps/survey.immich.app/src/lib/engines/builder-save.ts index 014adeb2..83510c18 100644 --- a/apps/survey.immich.app/src/lib/engines/builder-save.ts +++ b/apps/survey.immich.app/src/lib/engines/builder-save.ts @@ -24,23 +24,23 @@ function buildConditional( ): Conditional | undefined { const cfg = (config ?? {}) as SurveyQuestionConfig; const source = cfg.skipSourceQuestion; - if (!source) return undefined; + if (!source) {return undefined;} // Resolve the source reference to a real question id. Rules created before // the source question was persisted store a positional index instead of an id. let questionId = sectionQuestions.find((q) => q.id === source)?.id; if (!questionId) { const idx = Number(source); - if (Number.isInteger(idx)) questionId = sectionQuestions[idx]?.id; + if (Number.isInteger(idx)) {questionId = sectionQuestions[idx]?.id;} } - if (!questionId) return undefined; + if (!questionId) {return undefined;} const condition = cfg.skipConditionType ?? 'skipped'; const showIf: Conditional['showIf'] = { questionId, condition }; if (condition === 'equals' || condition === 'notEquals') { - showIf.value = String(cfg.skipConditionValue ?? ''); + showIf.value = (cfg.skipConditionValue ?? ''); } else if (condition === 'anyOf') { - showIf.values = String(cfg.skipConditionValues ?? '') + showIf.values = (cfg.skipConditionValues ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean); @@ -63,17 +63,17 @@ export async function saveSections( } for (const section of newSections) { - if (!section.id) { + if (section.id) { + await apiUpdateSection(surveyId, section.id, { + title: section.title, + description: section.description ?? undefined, + }); + } else { const created = await apiCreateSection(surveyId, { title: section.title, description: section.description ?? undefined, }); section.id = created.id; - } else { - await apiUpdateSection(surveyId, section.id, { - title: section.title, - description: section.description ?? undefined, - }); } } @@ -98,22 +98,7 @@ export async function saveSections( const hasOptions = (type: string) => ['radio', 'checkbox', 'dropdown'].includes(type); for (const q of section.questions) { - if (!q.id) { - const created = await apiCreateQuestion(surveyId, sectionId, { - text: q.text, - description: q.description || undefined, - type: q.type, - options: hasOptions(q.type) ? q.options : undefined, - required: q.required, - has_other: q.hasOther, - other_prompt: q.otherPrompt || undefined, - max_length: q.maxLength ?? undefined, - placeholder: q.placeholder || undefined, - config: q.config ?? undefined, - conditional: buildConditional(q.config, section.questions), - }); - q.id = created.id; - } else { + if (q.id) { await apiUpdateQuestion(surveyId, q.id, { section_id: sectionId, text: q.text, @@ -128,6 +113,21 @@ export async function saveSections( config: q.config ?? null, conditional: buildConditional(q.config, section.questions) ?? null, }); + } else { + const created = await apiCreateQuestion(surveyId, sectionId, { + text: q.text, + description: q.description || undefined, + type: q.type, + options: hasOptions(q.type) ? q.options : undefined, + required: q.required, + has_other: q.hasOther, + other_prompt: q.otherPrompt || undefined, + max_length: q.maxLength ?? undefined, + placeholder: q.placeholder || undefined, + config: q.config ?? undefined, + conditional: buildConditional(q.config, section.questions), + }); + q.id = created.id; } } diff --git a/apps/survey.immich.app/src/lib/engines/builder-transforms.ts b/apps/survey.immich.app/src/lib/engines/builder-transforms.ts index c0764985..87d1dd01 100644 --- a/apps/survey.immich.app/src/lib/engines/builder-transforms.ts +++ b/apps/survey.immich.app/src/lib/engines/builder-transforms.ts @@ -2,7 +2,7 @@ import type { QuestionType, Survey, SurveyQuestion, SurveySection } from '../typ import type { BuilderSection } from './builder-types'; function safeJsonParse(value: string | null | undefined, fallback: T): T { - if (!value) return fallback; + if (!value) {return fallback;} try { return JSON.parse(value) as T; } catch { diff --git a/apps/survey.immich.app/src/lib/engines/builder-utils.ts b/apps/survey.immich.app/src/lib/engines/builder-utils.ts index 5000ca32..6a9da7ee 100644 --- a/apps/survey.immich.app/src/lib/engines/builder-utils.ts +++ b/apps/survey.immich.app/src/lib/engines/builder-utils.ts @@ -16,16 +16,16 @@ export function estimateCompletionSeconds(sections: BuilderSection[]): number { let total = 0; for (const section of sections) { for (const q of section.questions) { - if (q.type === 'textarea') total += 45; - else if (q.type === 'text' || q.type === 'email') total += 20; - else total += 15; + if (q.type === 'textarea') {total += 45;} + else if (q.type === 'text' || q.type === 'email') {total += 20;} + else {total += 15;} } } return total; } export function formatDuration(seconds: number): string { - if (seconds < 60) return 'under 1 min'; + if (seconds < 60) {return 'under 1 min';} const mins = Math.round(seconds / 60); return `~${mins} min`; } diff --git a/apps/survey.immich.app/src/lib/engines/builder-validation.ts b/apps/survey.immich.app/src/lib/engines/builder-validation.ts index 6cec0e0d..2cf1594c 100644 --- a/apps/survey.immich.app/src/lib/engines/builder-validation.ts +++ b/apps/survey.immich.app/src/lib/engines/builder-validation.ts @@ -35,9 +35,9 @@ export function validateSurvey(title: string, sections: BuilderSection[]): strin } export function validateSlug(slug: string): string | null { - if (!slug) return 'Slug is required for publishing'; - if (slug.length < 3) return 'Slug must be at least 3 characters'; - if (slug.length > 50) return 'Slug must be at most 50 characters'; + if (!slug) {return 'Slug is required for publishing';} + if (slug.length < 3) {return 'Slug must be at least 3 characters';} + if (slug.length > 50) {return 'Slug must be at most 50 characters';} if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(slug)) { return 'Slug must be lowercase alphanumeric with hyphens, cannot start or end with a hyphen'; } diff --git a/apps/survey.immich.app/src/lib/engines/results-loader.svelte.ts b/apps/survey.immich.app/src/lib/engines/results-loader.svelte.ts index 16b6a066..51b63ad3 100644 --- a/apps/survey.immich.app/src/lib/engines/results-loader.svelte.ts +++ b/apps/survey.immich.app/src/lib/engines/results-loader.svelte.ts @@ -60,7 +60,7 @@ export function createResultsLoader(surveyId: string) { const sectionOrder = new SvelteMap(sections.map((s) => [s.id, s.sortOrder])); return [...questions].sort((a, b) => { const sectionDiff = (sectionOrder.get(a.section_id) ?? 0) - (sectionOrder.get(b.section_id) ?? 0); - if (sectionDiff !== 0) return sectionDiff; + if (sectionDiff !== 0) {return sectionDiff;} return a.sortOrder - b.sortOrder; }); }); @@ -95,16 +95,18 @@ export function createResultsLoader(surveyId: string) { } } - /** Coarsen the bucket size with the timestamp span so the chart isn't drowned in buckets. */ + /** + Coarsen the bucket size with the timestamp span so the chart isn't drowned in buckets. + */ function pickGranularity(data: TimelineDataPoint[]): Granularity { - if (data.length <= 1) return 'minute'; + if (data.length <= 1) {return 'minute';} // Server returns minute-granularity periods as "YYYY-MM-DDTHH:MM". const first = Date.parse(`${data[0].period}:00Z`); - const last = Date.parse(`${data[data.length - 1].period}:00Z`); + const last = Date.parse(`${data.at(-1).period}:00Z`); const spanMs = last - first; const hours = spanMs / (1000 * 60 * 60); - if (hours <= 2) return 'minute'; - if (hours <= 72) return 'hour'; + if (hours <= 2) {return 'minute';} + if (hours <= 72) {return 'hour';} return 'day'; } @@ -117,8 +119,8 @@ export function createResultsLoader(surveyId: string) { exporting = true; try { await exportResults(surveyId, format); - } catch (e) { - error = e instanceof Error ? e.message : 'Export failed'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Export failed'; } exporting = false; } @@ -144,16 +146,12 @@ export function createResultsLoader(surveyId: string) { liveCounts = resultsData.liveCounts; } granularity = pickGranularity(initialTimeline); - if (granularity === 'minute') { - timelineData = initialTimeline; - } else { - timelineData = await getSurveyTimeline(surveyId, granularity); - } + timelineData = granularity === 'minute' ? initialTimeline : (await getSurveyTimeline(surveyId, granularity)); dropoffData = dropoff; completionTimes = ctimes; questionTimings = qtimings; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load results'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to load results'; } loading = false; @@ -174,7 +172,7 @@ export function createResultsLoader(surveyId: string) { // Pushes carry only choice-question results — merge rather than // replace so text/email/number answers from the initial load survive. const updates: Record = {}; - for (const r of data.results) updates[r.questionId] = r; + for (const r of data.results) {updates[r.questionId] = r;} results = results.map((r) => updates[r.questionId] ?? r); }); diff --git a/apps/survey.immich.app/src/lib/engines/security.test.ts b/apps/survey.immich.app/src/lib/engines/security.test.ts index 3c3501e5..eaf98fc3 100644 --- a/apps/survey.immich.app/src/lib/engines/security.test.ts +++ b/apps/survey.immich.app/src/lib/engines/security.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; * Mirrors `constantTimeEqual` from backend/src/utils/crypto.ts */ 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); @@ -60,10 +60,10 @@ function isValidSurveyPassword(password: string): boolean { */ function isValidJwtStructure(token: string): boolean { const parts = token.split('.'); - if (parts.length !== 3) return false; + if (parts.length !== 3) {return false;} try { for (const part of parts.slice(0, 2)) { - JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))); + JSON.parse(atob(part.replaceAll('-', '+').replaceAll('_', '/'))); } return true; } catch { @@ -80,22 +80,22 @@ function createTestJwt(header: Record, payload: Record | null { const parts = token.split('.'); - if (parts.length !== 3) return null; + if (parts.length !== 3) {return null;} try { - return JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + return JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))); } catch { return null; } } function sanitizeForDisplay(input: string | null | undefined): string { - if (input == null) return ''; + if (input == null) {return '';} return input - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); } describe('Constant-time comparison — constantTimeEqual', () => { @@ -142,7 +142,7 @@ describe('Constant-time comparison — constantTimeEqual', () => { }); it('returns true for long identical strings', () => { - const long = 'a'.repeat(10000); + const long = 'a'.repeat(10_000); expect(constantTimeEqual(long, long)).toBe(true); }); @@ -455,7 +455,7 @@ describe('OIDC state validation', () => { expect(AUTH_STATE_COOKIE_NAME).toBe('auth_state'); // State cookies should expire — max-age must be finite and positive expect(SESSION_MAX_AGE).toBeGreaterThan(0); - expect(SESSION_MAX_AGE).toBeLessThanOrEqual(86400); // should not exceed 24 hours + expect(SESSION_MAX_AGE).toBeLessThanOrEqual(86_400); // should not exceed 24 hours }); }); @@ -472,9 +472,9 @@ describe('Survey password gate', () => { } function checkPassword(input: string): { status: number } { - if (!isValidSurveyPassword(input)) return { status: 400 }; + if (!isValidSurveyPassword(input)) {return { status: 400 };} // Simulate hash comparison (in real code this uses PBKDF2 + constantTimeEqual) - if (input !== 'correct-password') return { status: 403 }; + if (input !== 'correct-password') {return { status: 403 };} return { status: 200 }; } diff --git a/apps/survey.immich.app/src/lib/engines/survey-engine.svelte.ts b/apps/survey.immich.app/src/lib/engines/survey-engine.svelte.ts index e3d7838e..ecc552f2 100644 --- a/apps/survey.immich.app/src/lib/engines/survey-engine.svelte.ts +++ b/apps/survey.immich.app/src/lib/engines/survey-engine.svelte.ts @@ -1,22 +1,27 @@ import type { SurveyAnswer, SurveyQuestion, SurveySection } from '../types'; export function shouldShowQuestion(q: SurveyQuestion, answers: Record): boolean { - if (!q.conditional) return true; + if (!q.conditional) {return true;} const { questionId, condition, value, values } = q.conditional.showIf; const answer = answers[questionId]; switch (condition) { - case 'skipped': + case 'skipped': { return !(questionId in answers); - case 'equals': + } + case 'equals': { return answer !== undefined && answer.value === value; - case 'notEquals': + } + case 'notEquals': { return answer !== undefined && answer.value !== value; - case 'anyOf': + } + case 'anyOf': { return answer !== undefined && Array.isArray(values) && values.includes(answer.value); - default: + } + default: { return true; + } } } @@ -69,7 +74,7 @@ export function randomizeQuestions( ): SurveyQuestion[] { const grouped: Record = {}; for (const q of questions) { - if (!grouped[q.section_id]) grouped[q.section_id] = []; + if (!grouped[q.section_id]) {grouped[q.section_id] = [];} grouped[q.section_id].push(q); } @@ -83,7 +88,7 @@ export function randomizeQuestions( export function randomizeOptionOrder(questions: SurveyQuestion[], seed: string): SurveyQuestion[] { return questions.map((q) => { - if (!q.options || q.options.length <= 1) return q; + if (!q.options || q.options.length <= 1) {return q;} return { ...q, options: seededShuffle(q.options, seed + q.id) }; }); } @@ -124,13 +129,13 @@ export function createSurveyEngine( function goTo(questionId: string) { const idx = surveyQuestions.findIndex((q) => q.id === questionId); - if (idx >= 0) { + if (idx !== -1) { currentIndex = idx; } } function setAnswer(questionId: string, value: string, otherText?: string) { - answers[questionId] = { value, ...(otherText ? { otherText } : {}) }; + answers[questionId] = { value, ...(otherText && { otherText }) }; } function initialize(resumedAnswers: Record, resumeIndex: number) { diff --git a/apps/survey.immich.app/src/lib/engines/survey-loader.svelte.ts b/apps/survey.immich.app/src/lib/engines/survey-loader.svelte.ts index dc502508..8e41a8c1 100644 --- a/apps/survey.immich.app/src/lib/engines/survey-loader.svelte.ts +++ b/apps/survey.immich.app/src/lib/engines/survey-loader.svelte.ts @@ -49,7 +49,7 @@ export function createSurveyLoader(slug: string) { * what was just typed, not the stale pre-debounce (often empty) answer. */ function flushPending() { - for (const hook of preFlushHooks) hook(); + for (const hook of preFlushHooks) {hook();} } setContext('survey-pre-flush', { registerPreFlush, unregisterPreFlush, flushPending }); @@ -85,14 +85,14 @@ export function createSurveyLoader(slug: string) { (async () => { try { await loadAndInit(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to load survey'; } loading = false; })(); const handleUnload = () => { - for (const hook of preFlushHooks) hook(); + for (const hook of preFlushHooks) {hook();} client?.flushBufferSync(); }; window.addEventListener('beforeunload', handleUnload); @@ -126,9 +126,9 @@ export function createSurveyLoader(slug: string) { error = msg; }); client.onSaveSuccess(() => { - if (error) error = null; + if (error) {error = null;} }); - if (wsClient) client.setWsClient(wsClient); + if (wsClient) {client.setWsClient(wsClient);} const resume = await client.fetchResume(); if (resume.isComplete) { @@ -142,7 +142,7 @@ export function createSurveyLoader(slug: string) { const answered = resume.answers; let lastAnsweredIdx = -1; for (let i = 0; i < questions.length; i++) { - if (questions[i].id in answered) lastAnsweredIdx = i; + if (questions[i].id in answered) {lastAnsweredIdx = i;} } if (lastAnsweredIdx >= 0) { engine.initialize(resume.answers, lastAnsweredIdx); @@ -161,17 +161,17 @@ export function createSurveyLoader(slug: string) { function handleAnswer(questionId: string, value: string, otherText?: string) { engine?.setAnswer(questionId, value, otherText); const shownAt = questionShownAt[questionId]; - const answerMs = shownAt !== undefined ? Date.now() - shownAt : undefined; + const answerMs = shownAt === undefined ? undefined : Date.now() - shownAt; client?.bufferAnswer({ questionId, value, otherText, answerMs }); } async function handleComplete() { - if (!client) return; + if (!client) {return;} try { // Run the debounce hooks before flushing: submit is invoked synchronously // from handleNext, before Svelte tears the active component down, so its // onDestroy flush hasn't run and a just-typed answer would be lost. - for (const hook of preFlushHooks) hook(); + for (const hook of preFlushHooks) {hook();} const flushed = await client.flushBuffer(); if (!flushed) { error = 'Failed to save your answers. Please try again.'; @@ -179,8 +179,8 @@ export function createSurveyLoader(slug: string) { } await client.postComplete(); surveyFinished = true; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to submit survey. Please try again.'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to submit survey. Please try again.'; } } @@ -191,8 +191,8 @@ export function createSurveyLoader(slug: string) { error = null; try { await loadAndInit(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to load survey'; } loading = false; } diff --git a/apps/survey.immich.app/src/lib/engines/survey-templates.ts b/apps/survey.immich.app/src/lib/engines/survey-templates.ts index 1d438f4b..3b1a518d 100644 --- a/apps/survey.immich.app/src/lib/engines/survey-templates.ts +++ b/apps/survey.immich.app/src/lib/engines/survey-templates.ts @@ -10,7 +10,7 @@ export interface SurveyTemplate { function templateQuestion(templateId: string, sortOrder: number, overrides?: { text?: string }) { const t = questionTemplates.find((q) => q.id === templateId); - if (!t) throw new Error(`Template ${templateId} not found`); + if (!t) {throw new Error(`Template ${templateId} not found`);} return { ...t.question, id: '', sortOrder, ...overrides }; } diff --git a/apps/survey.immich.app/src/lib/stores/auth.svelte.ts b/apps/survey.immich.app/src/lib/stores/auth.svelte.ts index e802c04c..8473b6a2 100644 --- a/apps/survey.immich.app/src/lib/stores/auth.svelte.ts +++ b/apps/survey.immich.app/src/lib/stores/auth.svelte.ts @@ -18,7 +18,7 @@ let oidcEnabled = $state(false); let passwordEnabled = $state(true); export async function initAuth(): Promise { - if (checked) return; + if (checked) {return;} loading = true; try { const result = await getMe(); @@ -71,7 +71,7 @@ export function getAuth() { return user?.role ?? null; }, hasRole(minRole: UserRole): boolean { - if (!user) return false; + if (!user) {return false;} const hierarchy: Record = { admin: 3, editor: 2, viewer: 1 }; return hierarchy[user.role] >= hierarchy[minRole]; }, diff --git a/apps/survey.immich.app/src/lib/types.ts b/apps/survey.immich.app/src/lib/types.ts index 6a74e35c..93d1df35 100644 --- a/apps/survey.immich.app/src/lib/types.ts +++ b/apps/survey.immich.app/src/lib/types.ts @@ -24,7 +24,9 @@ export interface SurveyQuestionConfig { // Rating / Likert lowLabel?: string; highLabel?: string; - /** Legacy shape — some older surveys persisted labels as an object. */ + /** + Legacy shape — some older surveys persisted labels as an object. + */ scaleLabels?: { low: string; high: string }; // Text / textarea diff --git a/apps/survey.immich.app/src/routes/+layout.svelte b/apps/survey.immich.app/src/routes/+layout.svelte index 6d195501..4b12a34c 100644 --- a/apps/survey.immich.app/src/routes/+layout.svelte +++ b/apps/survey.immich.app/src/routes/+layout.svelte @@ -33,7 +33,7 @@ {@render children?.()} {:else if auth.loading}
    -
    +
    {:else if auth.needsSetup} diff --git a/apps/survey.immich.app/src/routes/+page.svelte b/apps/survey.immich.app/src/routes/+page.svelte index da01eaad..baa96c5d 100644 --- a/apps/survey.immich.app/src/routes/+page.svelte +++ b/apps/survey.immich.app/src/routes/+page.svelte @@ -51,8 +51,8 @@ }); surveys = result.surveys; totalSurveys = result.total; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load surveys'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to load surveys'; } loading = false; } @@ -72,12 +72,12 @@ } async function handleDelete(id: string) { - if (!confirm('Delete this survey? This cannot be undone.')) return; + if (!confirm('Delete this survey? This cannot be undone.')) {return;} try { await deleteSurvey(id); await loadSurveys(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to delete survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to delete survey'; } } @@ -86,8 +86,8 @@ await duplicateSurvey(id); page = 0; await loadSurveys(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to duplicate survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to duplicate survey'; } } @@ -95,8 +95,8 @@ try { await archiveSurvey(id); await loadSurveys(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to archive survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to archive survey'; } } @@ -104,8 +104,8 @@ try { await unarchiveSurvey(id); await loadSurveys(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to unarchive survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to unarchive survey'; } } @@ -118,11 +118,11 @@ const a = document.createElement('a'); a.href = url; const survey = surveys.find((s) => s.id === id); - a.download = `${survey?.slug ?? survey?.title.toLowerCase().replace(/\s+/g, '-') ?? id}-definition.json`; + a.download = `${survey?.slug ?? survey?.title.toLowerCase().replaceAll(/\s+/g, '-') ?? id}-definition.json`; a.click(); URL.revokeObjectURL(url); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to export survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to export survey'; } } @@ -132,8 +132,8 @@ showImportModal = false; page = 0; await loadSurveys(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to import survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to import survey'; } } @@ -187,7 +187,7 @@ />
  • @@ -195,7 +195,7 @@ {#if loading}
    -
    +

    Loading surveys...

    {:else if surveys.length === 0} @@ -217,7 +217,7 @@ Clear search {:else} -
    +

    No surveys yet

    @@ -234,7 +234,7 @@
    {#each surveys as survey, i (survey.id)}
    {#if survey.slug} - /s/{survey.slug} + /s/{survey.slug} {/if}
    diff --git a/apps/survey.immich.app/src/routes/audit/+page.svelte b/apps/survey.immich.app/src/routes/audit/+page.svelte index a5645840..f11b87b4 100644 --- a/apps/survey.immich.app/src/routes/audit/+page.svelte +++ b/apps/survey.immich.app/src/routes/audit/+page.svelte @@ -19,8 +19,8 @@ const data = await getAuditLog(offset, limit); entries = data.entries; total = data.total; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load audit log'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to load audit log'; } loading = false; } @@ -47,7 +47,7 @@ {#if loading}
    -
    +

    Loading audit log...

    {:else if entries.length === 0} diff --git a/apps/survey.immich.app/src/routes/create/+page.svelte b/apps/survey.immich.app/src/routes/create/+page.svelte index 14ff9f7b..11abd0b4 100644 --- a/apps/survey.immich.app/src/routes/create/+page.svelte +++ b/apps/survey.immich.app/src/routes/create/+page.svelte @@ -168,7 +168,7 @@ onclick={startBlank} >
    @@ -184,7 +184,7 @@ onclick={() => startFromTemplate(template)} >
    diff --git a/apps/survey.immich.app/src/routes/edit/[id]/+page.svelte b/apps/survey.immich.app/src/routes/edit/[id]/+page.svelte index f5fc815e..c583af6a 100644 --- a/apps/survey.immich.app/src/routes/edit/[id]/+page.svelte +++ b/apps/survey.immich.app/src/routes/edit/[id]/+page.svelte @@ -28,8 +28,8 @@ wsClient = createSurveyWsClient(survey.slug, 'editor'); registerWsClient(surveyId, wsClient); } - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load survey'; + } catch (error_) { + error = error_ instanceof Error ? error_.message : 'Failed to load survey'; } loading = false; }); @@ -39,21 +39,21 @@ }); async function handleSaveSurvey(updates: Partial & { password?: string | null }) { - if (!survey) return; + if (!survey) {return;} saving = true; try { survey = await updateSurvey(survey.id, { title: updates.title ?? undefined, - description: updates.description !== undefined ? updates.description : undefined, - slug: updates.slug !== undefined ? updates.slug : undefined, - welcome_title: updates.welcomeTitle !== undefined ? updates.welcomeTitle : undefined, - welcome_description: updates.welcomeDescription !== undefined ? updates.welcomeDescription : undefined, - thank_you_title: updates.thankYouTitle !== undefined ? updates.thankYouTitle : undefined, - thank_you_description: updates.thankYouDescription !== undefined ? updates.thankYouDescription : undefined, - closes_at: updates.closesAt !== undefined ? updates.closesAt : undefined, - max_responses: updates.maxResponses !== undefined ? updates.maxResponses : undefined, - randomize_questions: updates.randomizeQuestions !== undefined ? updates.randomizeQuestions : undefined, - randomize_options: updates.randomizeOptions !== undefined ? updates.randomizeOptions : undefined, + description: updates.description === undefined ? undefined : updates.description, + slug: updates.slug === undefined ? undefined : updates.slug, + welcome_title: updates.welcomeTitle === undefined ? undefined : updates.welcomeTitle, + welcome_description: updates.welcomeDescription === undefined ? undefined : updates.welcomeDescription, + thank_you_title: updates.thankYouTitle === undefined ? undefined : updates.thankYouTitle, + thank_you_description: updates.thankYouDescription === undefined ? undefined : updates.thankYouDescription, + closes_at: updates.closesAt === undefined ? undefined : updates.closesAt, + max_responses: updates.maxResponses === undefined ? undefined : updates.maxResponses, + randomize_questions: updates.randomizeQuestions === undefined ? undefined : updates.randomizeQuestions, + randomize_options: updates.randomizeOptions === undefined ? undefined : updates.randomizeOptions, password: updates.password, }); } finally { @@ -62,7 +62,7 @@ } async function handleSaveSections(newSections: BuilderSection[]) { - if (!survey) return; + if (!survey) {return;} saving = true; try { await saveSections(survey.id, sections, newSections, allQuestions); @@ -77,12 +77,12 @@ } async function handlePublish() { - if (!survey) return; + if (!survey) {return;} survey = await publishSurvey(survey.id); } async function handleUnpublish() { - if (!survey) return; + if (!survey) {return;} survey = await unpublishSurvey(survey.id); } diff --git a/apps/survey.immich.app/src/routes/results/[id]/+page.svelte b/apps/survey.immich.app/src/routes/results/[id]/+page.svelte index 2ed77d20..4c669af6 100644 --- a/apps/survey.immich.app/src/routes/results/[id]/+page.svelte +++ b/apps/survey.immich.app/src/routes/results/[id]/+page.svelte @@ -22,7 +22,7 @@
    {#if loader.loading}
    -
    +

    Loading results...

    {:else if loader.error} @@ -87,7 +87,7 @@ {#if loader.activeTab === 'overview'}
    @@ -97,7 +97,7 @@

    respondents

    @@ -111,7 +111,7 @@

    Completion

    @@ -122,7 +122,7 @@

    Live now