From 09db2383300f4f0b1e63276044ef0d804ccb234f Mon Sep 17 00:00:00 2001 From: Antoine Lecompte <38678863+nutgood@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:36:45 -0400 Subject: [PATCH] feat: add futo backup surveys (#406) --- .github/workflows/build.yml | 37 +- .gitignore | 8 + .../backend/.gitignore | 3 + .../backend/migrations/0001_initial.sql | 21 + .../backend/migrations/0002_cookie_dedup.sql | 8 + .../migrations/0003_turnstile_verified.sql | 5 + .../backend/package.json | 17 + .../backend/src/cookie.ts | 22 + .../backend/src/index.ts | 183 ++++++ .../backend/src/survey-definition.ts | 29 + .../backend/tsconfig.json | 19 + .../backend/worker-configuration.d.ts | 4 + .../backend/wrangler.jsonc | 19 + .../eslint.config.js | 37 ++ .../package.json | 48 ++ .../src/app.d.ts | 12 + .../src/app.html | 15 + .../src/lib/api-client.test.ts | 185 ++++++ .../src/lib/api-client.ts | 122 ++++ .../src/lib/app.css | 10 + .../lib/components/AlreadyCompleted.svelte | 17 + .../src/lib/components/EmailQuestion.svelte | 40 ++ .../lib/components/EmailSignupQuestion.svelte | 88 +++ .../src/lib/components/QuestionCard.svelte | 61 ++ .../src/lib/components/RadioQuestion.svelte | 73 +++ .../src/lib/components/SectionHeader.svelte | 41 ++ .../src/lib/components/SurveyShell.svelte | 150 +++++ .../lib/components/TextareaQuestion.svelte | 44 ++ .../src/lib/components/ThankYouScreen.svelte | 53 ++ .../src/lib/components/WelcomeScreen.svelte | 46 ++ .../src/lib/survey-definition.test.ts | 88 +++ .../src/lib/survey-definition.ts | 363 +++++++++++ .../src/lib/survey-engine.svelte.ts | 133 ++++ .../src/lib/survey-engine.test.ts | 66 ++ .../src/lib/types.ts | 36 ++ .../src/routes/+layout.svelte | 15 + .../src/routes/+layout.ts | 1 + .../src/routes/+page.svelte | 96 +++ .../survey-questions.md | 179 ++++++ .../svelte.config.js | 24 + .../tsconfig.json | 15 + .../vite.config.ts | 21 + .../cloudflare/static-pages/remote-state.tf | 2 +- .../workers/futo-backups-survey/config.tf | 11 + .../workers/futo-backups-survey/d1.tf | 25 + .../workers/futo-backups-survey/locals.tf | 5 + .../workers/futo-backups-survey/providers.tf | 3 + .../futo-backups-survey/remote-state.tf | 17 + .../futo-backups-survey/terragrunt.hcl | 26 + .../workers/futo-backups-survey/variables.tf | 7 + .../workers/futo-backups-survey/worker.tf | 41 ++ pnpm-lock.yaml | 588 +++++++++++++++++- pnpm-workspace.yaml | 1 + 53 files changed, 3167 insertions(+), 13 deletions(-) create mode 100644 apps/futo-backups-survey.immich.app/backend/.gitignore create mode 100644 apps/futo-backups-survey.immich.app/backend/migrations/0001_initial.sql create mode 100644 apps/futo-backups-survey.immich.app/backend/migrations/0002_cookie_dedup.sql create mode 100644 apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql create mode 100644 apps/futo-backups-survey.immich.app/backend/package.json create mode 100644 apps/futo-backups-survey.immich.app/backend/src/cookie.ts create mode 100644 apps/futo-backups-survey.immich.app/backend/src/index.ts create mode 100644 apps/futo-backups-survey.immich.app/backend/src/survey-definition.ts create mode 100644 apps/futo-backups-survey.immich.app/backend/tsconfig.json create mode 100644 apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts create mode 100644 apps/futo-backups-survey.immich.app/backend/wrangler.jsonc create mode 100644 apps/futo-backups-survey.immich.app/eslint.config.js create mode 100644 apps/futo-backups-survey.immich.app/package.json create mode 100644 apps/futo-backups-survey.immich.app/src/app.d.ts create mode 100644 apps/futo-backups-survey.immich.app/src/app.html create mode 100644 apps/futo-backups-survey.immich.app/src/lib/api-client.test.ts create mode 100644 apps/futo-backups-survey.immich.app/src/lib/api-client.ts create mode 100644 apps/futo-backups-survey.immich.app/src/lib/app.css create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/AlreadyCompleted.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/EmailQuestion.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/EmailSignupQuestion.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/QuestionCard.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/RadioQuestion.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/SectionHeader.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/SurveyShell.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/TextareaQuestion.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/ThankYouScreen.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/components/WelcomeScreen.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/lib/survey-definition.test.ts create mode 100644 apps/futo-backups-survey.immich.app/src/lib/survey-definition.ts create mode 100644 apps/futo-backups-survey.immich.app/src/lib/survey-engine.svelte.ts create mode 100644 apps/futo-backups-survey.immich.app/src/lib/survey-engine.test.ts create mode 100644 apps/futo-backups-survey.immich.app/src/lib/types.ts create mode 100644 apps/futo-backups-survey.immich.app/src/routes/+layout.svelte create mode 100644 apps/futo-backups-survey.immich.app/src/routes/+layout.ts create mode 100644 apps/futo-backups-survey.immich.app/src/routes/+page.svelte create mode 100644 apps/futo-backups-survey.immich.app/survey-questions.md create mode 100644 apps/futo-backups-survey.immich.app/svelte.config.js create mode 100644 apps/futo-backups-survey.immich.app/tsconfig.json create mode 100644 apps/futo-backups-survey.immich.app/vite.config.ts create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/config.tf create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/d1.tf create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/locals.tf create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/providers.tf create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/remote-state.tf create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/terragrunt.hcl create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/variables.tf create mode 100644 deployment/modules/cloudflare/workers/futo-backups-survey/worker.tf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 546e1a36..d6daaaca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,11 +22,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets'] + app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey'] + include: + - app_name: futo-backups-survey + app_dir: futo-backups-survey.immich.app defaults: run: - working-directory: apps/${{ matrix.app_name }}.immich.app + working-directory: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }} steps: - name: Checkout code @@ -58,7 +61,7 @@ jobs: with: name: build-frontend-output-${{ matrix.app_name }} include-hidden-files: true - path: apps/${{ matrix.app_name }}.immich.app/build + path: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/${{ matrix.build_path || 'build' }} retention-days: 1 build-backend: @@ -66,11 +69,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - app_name: ['datasets'] + app_name: ['datasets', 'futo-backups-survey'] + include: + - app_name: futo-backups-survey + app_dir: futo-backups-survey.immich.app defaults: run: - working-directory: apps/${{ matrix.app_name }}.immich.app/backend + working-directory: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend steps: - name: Checkout code @@ -98,7 +104,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: build-backend-output-${{ matrix.app_name }} - path: 'apps/${{ matrix.app_name }}.immich.app/backend/dist' + path: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend/dist retention-days: 1 deploy: @@ -108,11 +114,15 @@ jobs: strategy: fail-fast: false matrix: - app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets'] + app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey'] + include: + - app_name: futo-backups-survey + app_dir: futo-backups-survey.immich.app env: TF_VAR_app_name: ${{ matrix.app_name }} TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }} - TF_VAR_dist_dir: ${{github.workspace}}/dist/backend + TF_VAR_dist_dir: ${{ github.workspace }}/dist/backend + TF_VAR_migrations_dir: ${{ github.workspace }}/apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend/migrations OP_SERVICE_ACCOUNT_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.OP_TF_PROD_ENV || secrets.OP_TF_DEV_ENV }} steps: - name: Checkout code @@ -129,7 +139,8 @@ jobs: - name: Check if the app has a backend folder id: check-backend run: | - if [ -d "apps/${{ matrix.app_name }}.immich.app/backend" ]; then + APP_DIR="${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}" + if [ -d "apps/${APP_DIR}/backend" ]; then echo "has_backend=true" >> $GITHUB_ENV else echo "has_backend=false" >> $GITHUB_ENV @@ -164,11 +175,15 @@ jobs: INCLUDE_DIRS='**/workers/${{ matrix.app_name }}' fi + QUEUE_ARGS=(--queue-include-dir='**/!(workers)/**') + if [ -n "$INCLUDE_DIRS" ]; then + QUEUE_ARGS+=(--queue-include-dir="$INCLUDE_DIRS") + fi + op run --env-file='.env' -- terragrunt run --all apply \ --non-interactive \ --parallelism 1 \ - --queue-include-dir='**/!(workers)/**' \ - --queue-include-dir="$INCLUDE_DIRS" + "${QUEUE_ARGS[@]}" - name: Cloudflare Deploy Output id: deploy-output diff --git a/.gitignore b/.gitignore index 06fcd0ad..f8beb415 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,14 @@ node_modules +# IDEs +.idea + # Output .output .vercel apps/*/.svelte-kit apps/*/build +apps/*/.wrangler # OS .DS_Store @@ -19,3 +23,7 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Planning +.planning/ +apps/*/.planning diff --git a/apps/futo-backups-survey.immich.app/backend/.gitignore b/apps/futo-backups-survey.immich.app/backend/.gitignore new file mode 100644 index 00000000..234e8b50 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist +.wrangler/ diff --git a/apps/futo-backups-survey.immich.app/backend/migrations/0001_initial.sql b/apps/futo-backups-survey.immich.app/backend/migrations/0001_initial.sql new file mode 100644 index 00000000..339a3ba0 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/migrations/0001_initial.sql @@ -0,0 +1,21 @@ +-- migrations/0001_initial.sql +-- Initial schema: respondents and answers tables for survey persistence + +CREATE TABLE respondents ( + id TEXT PRIMARY KEY, + ip_address TEXT NOT NULL, + is_complete INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + completed_at TEXT +); + +CREATE UNIQUE INDEX idx_respondents_ip ON respondents(ip_address); + +CREATE TABLE answers ( + respondent_id TEXT NOT NULL, + question_id TEXT NOT NULL, + answer TEXT NOT NULL, + answered_at TEXT NOT NULL, + PRIMARY KEY (respondent_id, question_id), + FOREIGN KEY (respondent_id) REFERENCES respondents(id) +); diff --git a/apps/futo-backups-survey.immich.app/backend/migrations/0002_cookie_dedup.sql b/apps/futo-backups-survey.immich.app/backend/migrations/0002_cookie_dedup.sql new file mode 100644 index 00000000..b2446c27 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/migrations/0002_cookie_dedup.sql @@ -0,0 +1,8 @@ +-- migrations/0002_cookie_dedup.sql +-- Switch from IP-based to cookie-based deduplication. +-- IP column stays for analytics but is no longer a uniqueness constraint. +-- Add other_text column for "Other" free-text responses on Q7, Q13-Q16. + +DROP INDEX IF EXISTS idx_respondents_ip; + +ALTER TABLE answers ADD COLUMN other_text TEXT; diff --git a/apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql b/apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql new file mode 100644 index 00000000..806f4339 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql @@ -0,0 +1,5 @@ +-- migrations/0003_turnstile_verified.sql +-- Track whether the respondent passed a Cloudflare Turnstile challenge. +-- Existing respondents default to unverified (0). + +ALTER TABLE respondents ADD COLUMN is_verified INTEGER DEFAULT 0; diff --git a/apps/futo-backups-survey.immich.app/backend/package.json b/apps/futo-backups-survey.immich.app/backend/package.json new file mode 100644 index 00000000..62e30728 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "futo-backups-survey-backend", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "cf-typegen": "wrangler types" + }, + "devDependencies": { + "typescript": "^5.9.2", + "wrangler": "^4.75.0" + }, + "dependencies": { + "itty-router": "^5.0.18" + } +} diff --git a/apps/futo-backups-survey.immich.app/backend/src/cookie.ts b/apps/futo-backups-survey.immich.app/backend/src/cookie.ts new file mode 100644 index 00000000..d47db810 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/src/cookie.ts @@ -0,0 +1,22 @@ +const COOKIE_NAME = 'respondent_id'; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 90; // 90 days + +export function getRespondentId(request: Request): string | undefined { + const header = request.headers.get('Cookie') ?? ''; + const match = header.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`)); + return match?.[1]; +} + +export function setRespondentCookie(headers: Headers, respondentId: string): void { + headers.set( + 'Set-Cookie', + `${COOKIE_NAME}=${respondentId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`, + ); +} + +export function deleteRespondentCookie(headers: Headers): void { + headers.set( + 'Set-Cookie', + `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`, + ); +} diff --git a/apps/futo-backups-survey.immich.app/backend/src/index.ts b/apps/futo-backups-survey.immich.app/backend/src/index.ts new file mode 100644 index 00000000..32be2702 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/src/index.ts @@ -0,0 +1,183 @@ +import { AutoRouter, cors, IRequest } from 'itty-router'; +import { getRespondentId, setRespondentCookie, deleteRespondentCookie } from './cookie'; +import { questions } from './survey-definition'; + +const { preflight, corsify } = cors(); + +const router = AutoRouter({ + before: [preflight], + finally: [corsify], +}); + +router.post('/api/verify', async (request, env) => { + const db = env.DB; + const { turnstileToken } = (await request.json()) as { turnstileToken: string }; + + if (!turnstileToken) { + return new Response('Missing turnstile token', { status: 400 }); + } + + // dev environment — skip Cloudflare call + if (env.CF_TURNSTILE_SECRET !== 'DEV_TURNSTILE_TOKEN') { + const ip = request.headers.get('CF-Connecting-IP'); + if (!ip) { + return new Response('Missing client IP', { status: 400 }); + } + + const formData = new FormData(); + formData.append('secret', env.CF_TURNSTILE_SECRET); + formData.append('response', turnstileToken); + formData.append('remoteip', ip); + + const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + body: formData, + method: 'POST', + }); + + const outcome = (await result.json()) as { success: boolean }; + if (!outcome.success) { + return new Response('Turnstile validation failed', { status: 403 }); + } + } + + const respondentId = getRespondentId(request); + if (respondentId) { + await db + .prepare('UPDATE respondents SET is_verified = 1 WHERE id = ?') + .bind(respondentId) + .run(); + } + + return Response.json({ success: true }); +}); + +router.post('/api/answers', async (request, env) => { + const db = env.DB; + const { questionId, value, otherText } = (await request.json()) as { + questionId: string; + value: string; + otherText?: string; + }; + + const ip = + request.headers.get('CF-Connecting-IP') ?? + request.headers.get('x-forwarded-for') ?? + 'unknown'; + + let respondentId = getRespondentId(request); + const headers = new Headers(); + + if (!respondentId) { + respondentId = crypto.randomUUID(); + setRespondentCookie(headers, respondentId); + } + + await db + .prepare( + `INSERT INTO respondents (id, ip_address, created_at) VALUES (?, ?, ?) + ON CONFLICT (id) DO UPDATE SET ip_address = excluded.ip_address`, + ) + .bind(respondentId, ip, new Date().toISOString()) + .run(); + + await db + .prepare( + `INSERT INTO answers (respondent_id, question_id, answer, other_text, answered_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (respondent_id, question_id) + DO UPDATE SET answer = excluded.answer, other_text = excluded.other_text, answered_at = excluded.answered_at`, + ) + .bind(respondentId, questionId, value, otherText ?? null, new Date().toISOString()) + .run(); + + return new Response(null, { status: 204, headers }); +}); + +router.get('/api/resume', async (request, env) => { + const db = env.DB; + let respondentId = getRespondentId(request); + const headers = new Headers(); + + if (!respondentId) { + respondentId = crypto.randomUUID(); + const ip = + request.headers.get('CF-Connecting-IP') ?? + request.headers.get('x-forwarded-for') ?? + 'unknown'; + await db + .prepare('INSERT INTO respondents (id, ip_address, created_at) VALUES (?, ?, ?)') + .bind(respondentId, ip, new Date().toISOString()) + .run(); + setRespondentCookie(headers, respondentId); + return Response.json({ answers: {}, nextQuestionIndex: 0 }, { headers }); + } + + const respondent = await db + .prepare('SELECT id, is_complete, is_verified FROM respondents WHERE id = ?') + .bind(respondentId) + .first<{ id: string; is_complete: number; is_verified: number }>(); + + if (!respondent) { + return Response.json({ answers: {}, nextQuestionIndex: 0 }, { headers }); + } + + if (respondent.is_complete) { + return Response.json({ isComplete: true }, { headers }); + } + + const rows = await db + .prepare('SELECT question_id, answer, other_text FROM answers WHERE respondent_id = ?') + .bind(respondentId) + .all<{ question_id: string; answer: string; other_text: string | null }>(); + + const answers: Record = {}; + for (const row of rows.results) { + answers[row.question_id] = { + value: row.answer, + ...(row.other_text ? { otherText: row.other_text } : {}), + }; + } + + let nextQuestionIndex = questions.length; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + + if (q.conditional?.showIf.condition === 'skipped') { + const depId = q.conditional.showIf.questionId; + if (answers[depId]) { + continue; + } + } + + if (!answers[q.id]) { + nextQuestionIndex = i; + break; + } + } + + return Response.json({ answers, nextQuestionIndex, isVerified: !!respondent.is_verified }, { headers }); +}); + +router.post('/api/complete', async (request, env) => { + const db = env.DB; + const respondentId = getRespondentId(request); + + if (!respondentId) { + return new Response('No respondent cookie', { status: 400 }); + } + + await db + .prepare('UPDATE respondents SET is_complete = 1, completed_at = ? WHERE id = ?') + .bind(new Date().toISOString(), respondentId) + .run(); + + return new Response(null, { status: 204 }); +}); + +router.post('/api/reset', async (request) => { + const headers = new Headers(); + deleteRespondentCookie(headers); + return new Response(null, { status: 204, headers }); +}); + +export default router; diff --git a/apps/futo-backups-survey.immich.app/backend/src/survey-definition.ts b/apps/futo-backups-survey.immich.app/backend/src/survey-definition.ts new file mode 100644 index 00000000..02a61e91 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/src/survey-definition.ts @@ -0,0 +1,29 @@ +interface SurveyQuestion { + id: string; + conditional?: { + showIf: { questionId: string; condition: 'skipped' }; + }; +} + +const opt = (label: string) => ({ label, value: label }); + +export const questions: SurveyQuestion[] = [ + { id: 'q1' }, + { id: 'q2' }, + { id: 'q3' }, + { id: 'q4' }, + { id: 'q5' }, + { id: 'q6' }, + { id: 'q7' }, + { id: 'q8' }, + { id: 'q9' }, + { id: 'q10' }, + { id: 'q11' }, + { id: 'q12' }, + { id: 'q17' }, + { id: 'q13' }, + { id: 'q14' }, + { id: 'q15' }, + { id: 'q16' }, + { id: 'q19' }, +]; diff --git a/apps/futo-backups-survey.immich.app/backend/tsconfig.json b/apps/futo-backups-survey.immich.app/backend/tsconfig.json new file mode 100644 index 00000000..0d7a38a8 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["./worker-configuration.d.ts"] + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts b/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts new file mode 100644 index 00000000..aedf717f --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts @@ -0,0 +1,4 @@ +interface Env { + DB: D1Database; + CF_TURNSTILE_SECRET: string; +} diff --git a/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc b/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc new file mode 100644 index 00000000..3e852ba5 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "futo-backups-survey-api", + "main": "src/index.ts", + "compatibility_date": "2025-06-03", + "observability": { + "enabled": true + }, + "vars": { + "CF_TURNSTILE_SECRET": "DEV_TURNSTILE_TOKEN" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "futo-backups-survey", + "database_id": "local" + } + ] +} diff --git a/apps/futo-backups-survey.immich.app/eslint.config.js b/apps/futo-backups-survey.immich.app/eslint.config.js new file mode 100644 index 00000000..ac864109 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/eslint.config.js @@ -0,0 +1,37 @@ +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', + }, + }, + { + ignores: ['build/', '.svelte-kit/', 'dist/'], + }, +]; diff --git a/apps/futo-backups-survey.immich.app/package.json b/apps/futo-backups-survey.immich.app/package.json new file mode 100644 index 00000000..55086feb --- /dev/null +++ b/apps/futo-backups-survey.immich.app/package.json @@ -0,0 +1,48 @@ +{ + "name": "futo-backups.surveys.immich.app", + "private": true, + "version": "0.0.1", + "license": "GNU Affero General Public License version 3", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "svelte-kit sync && vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "sync": "svelte-kit sync", + "test": "vitest", + "lint": "eslint . --max-warnings 0", + "lint:fix": "npm run lint -- --fix", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "db:migrate:local": "cd backend && wrangler d1 migrations apply futo-backups-survey --local" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.1.13", + "@types/node": "^24.12.0", + "dotenv": "^17.3.1", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^17.0.0", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7", + "vitest": "^4.0.12" + }, + "dependencies": { + "@immich/ui": "^0.65.0", + "@mdi/js": "^7.4.47", + "svelte-turnstile": "^0.11.0" + } +} diff --git a/apps/futo-backups-survey.immich.app/src/app.d.ts b/apps/futo-backups-survey.immich.app/src/app.d.ts new file mode 100644 index 00000000..8bcbd814 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/apps/futo-backups-survey.immich.app/src/app.html b/apps/futo-backups-survey.immich.app/src/app.html new file mode 100644 index 00000000..d87f8ff8 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/app.html @@ -0,0 +1,15 @@ + + + + + + + FUTO Backups Survey + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/futo-backups-survey.immich.app/src/lib/api-client.test.ts b/apps/futo-backups-survey.immich.app/src/lib/api-client.test.ts new file mode 100644 index 00000000..38dcd854 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/api-client.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Module will be imported after it exists +let saveAnswer: typeof import('./api-client').saveAnswer; +let fireAndForgetSave: typeof import('./api-client').fireAndForgetSave; +let flushPendingQueue: typeof import('./api-client').flushPendingQueue; +let getPendingQueue: typeof import('./api-client').getPendingQueue; +let fetchResume: typeof import('./api-client').fetchResume; +let postComplete: typeof import('./api-client').postComplete; + +beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn()); + // Re-import module fresh each test to reset pendingQueue + vi.resetModules(); + const mod = await import('./api-client'); + saveAnswer = mod.saveAnswer; + fireAndForgetSave = mod.fireAndForgetSave; + flushPendingQueue = mod.flushPendingQueue; + getPendingQueue = mod.getPendingQueue; + fetchResume = mod.fetchResume; + postComplete = mod.postComplete; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('saveAnswer', () => { + it('calls fetch with correct body and headers', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }); + vi.stubGlobal('fetch', mockFetch); + + const data = { questionId: 'q1', value: 'test' }; + const promise = saveAnswer(data); + await vi.runAllTimersAsync(); + await promise; + + expect(mockFetch).toHaveBeenCalledWith('/api/answers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'same-origin', + }); + }); + + it('returns true on 204 response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 })); + + const promise = saveAnswer({ questionId: 'q1', value: 'test' }); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe(true); + }); + + it('retries on 500 response - fetch called 4 times total', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + vi.stubGlobal('fetch', mockFetch); + + const promise = saveAnswer({ questionId: 'q1', value: 'test' }); + await vi.runAllTimersAsync(); + await promise; + + expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }); + + it('does NOT retry on 400 response - fetch called once', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400 }); + vi.stubGlobal('fetch', mockFetch); + + const promise = saveAnswer({ questionId: 'q1', value: 'test' }); + await vi.runAllTimersAsync(); + await promise; + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('retries on network error (fetch throws)', async () => { + const mockFetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + vi.stubGlobal('fetch', mockFetch); + + const promise = saveAnswer({ questionId: 'q1', value: 'test' }); + await vi.runAllTimersAsync(); + await promise; + + expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }); + + it('uses delays of 1000, 2000, 4000 ms between retries', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + vi.stubGlobal('fetch', mockFetch); + + const promise = saveAnswer({ questionId: 'q1', value: 'test' }); + + // Initial call happens immediately + await vi.advanceTimersByTimeAsync(0); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // After 1000ms: first retry + await vi.advanceTimersByTimeAsync(1000); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // After 2000ms more: second retry + await vi.advanceTimersByTimeAsync(2000); + expect(mockFetch).toHaveBeenCalledTimes(3); + + // After 4000ms more: third retry + await vi.advanceTimersByTimeAsync(4000); + expect(mockFetch).toHaveBeenCalledTimes(4); + + await promise; + }); +}); + +describe('fireAndForgetSave', () => { + it('adds to pendingQueue on total failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); + + fireAndForgetSave({ questionId: 'q1', value: 'test' }); + await vi.runAllTimersAsync(); + // Allow microtasks to settle + await vi.advanceTimersByTimeAsync(0); + + expect(getPendingQueue()).toHaveLength(1); + expect(getPendingQueue()[0]).toEqual({ questionId: 'q1', value: 'test' }); + }); +}); + +describe('flushPendingQueue', () => { + it('re-fires queued items', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + vi.stubGlobal('fetch', mockFetch); + + // First: make a save fail to populate the queue + fireAndForgetSave({ questionId: 'q1', value: 'test' }); + await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); + expect(getPendingQueue()).toHaveLength(1); + + // Now make fetch succeed and flush + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + const callsBefore = mockFetch.mock.calls.length; + flushPendingQueue(); + await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); + + // Queue should be empty now (re-fired successfully) + expect(getPendingQueue()).toHaveLength(0); + // fetch should have been called at least once more + expect(mockFetch.mock.calls.length).toBeGreaterThan(callsBefore); + }); +}); + +describe('fetchResume', () => { + it('calls GET /api/resume with credentials: same-origin', async () => { + const mockResponse = { answers: {}, nextQuestionIndex: 0 }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }), + ); + + const result = await fetchResume(); + + expect(fetch).toHaveBeenCalledWith('/api/resume', { credentials: 'same-origin' }); + expect(result).toEqual(mockResponse); + }); +}); + +describe('postComplete', () => { + it('calls POST /api/complete with credentials: same-origin', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 })); + + await postComplete(); + + expect(fetch).toHaveBeenCalledWith('/api/complete', { + method: 'POST', + credentials: 'same-origin', + }); + }); +}); diff --git a/apps/futo-backups-survey.immich.app/src/lib/api-client.ts b/apps/futo-backups-survey.immich.app/src/lib/api-client.ts new file mode 100644 index 00000000..e86c8dcb --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/api-client.ts @@ -0,0 +1,122 @@ +import type { SurveyAnswer } from './types'; + +/** + * Verifies a Cloudflare Turnstile token with the backend. + */ +export async function verifyTurnstile(turnstileToken: string): Promise { + const res = await fetch('/api/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ turnstileToken }), + credentials: 'same-origin', + }); + if (!res.ok) { + throw new Error('Challenge verification failed. Please try again.'); + } +} + +interface PendingSave { + questionId: string; + value: string; + otherText?: string; +} + +let pendingQueue: PendingSave[] = []; + +const BACKOFF_DELAYS = [1000, 2000, 4000]; + +/** + * Saves an answer to the server with exponential backoff retry. + * Retries up to 3 times on server errors (5xx) and network errors. + * Does NOT retry on client errors (4xx). + */ +export async function saveAnswer(data: PendingSave): Promise { + for (let attempt = 0; attempt <= BACKOFF_DELAYS.length; attempt++) { + try { + const res = await fetch('/api/answers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'same-origin', + }); + if (res.ok) return true; + if (res.status < 500) return false; // client error, don't retry + } catch { + // network error, retry + } + if (attempt < BACKOFF_DELAYS.length) { + await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[attempt])); + } + } + return false; +} + +let onSaveErrorCallback: ((message: string) => void) | null = null; + +/** + * Registers a callback invoked when a save fails after all retries. + */ +export function onSaveError(cb: (message: string) => void): void { + onSaveErrorCallback = cb; +} + +/** + * Fire-and-forget: saves an answer without blocking. + * On total failure (all retries exhausted), queues for later retry and notifies via callback. + */ +export function fireAndForgetSave(data: PendingSave): void { + saveAnswer(data).then((success) => { + if (!success) { + pendingQueue.push(data); + onSaveErrorCallback?.('Failed to save answer. Your response will be retried automatically.'); + } + }); +} + +/** + * Re-attempts all queued saves that previously failed. + */ +export function flushPendingQueue(): void { + const queue = [...pendingQueue]; + pendingQueue = []; + for (const item of queue) { + fireAndForgetSave(item); + } +} + +/** + * Returns the current pending queue (for testing). + */ +export function getPendingQueue(): PendingSave[] { + return pendingQueue; +} + +/** + * Fetches resume data from the server. + * Returns existing answers and next question index, or isComplete flag. + */ +export async function fetchResume(): Promise<{ + answers?: Record; + nextQuestionIndex?: number; + isComplete?: boolean; + isVerified?: boolean; +}> { + const res = await fetch('/api/resume', { credentials: 'same-origin' }); + if (!res.ok) { + throw new Error(`Failed to load survey (${res.status})`); + } + return res.json(); +} + +/** + * Marks the survey as complete on the server. + */ +export async function postComplete(): Promise { + const res = await fetch('/api/complete', { + method: 'POST', + credentials: 'same-origin', + }); + if (!res.ok) { + throw new Error(`Failed to submit survey (${res.status})`); + } +} diff --git a/apps/futo-backups-survey.immich.app/src/lib/app.css b/apps/futo-backups-survey.immich.app/src/lib/app.css new file mode 100644 index 00000000..5b391d05 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/app.css @@ -0,0 +1,10 @@ +@import 'tailwindcss'; +@import '@immich/ui/theme/default.css'; +@import '$common/theme/theme.css'; +@source '../../node_modules/@immich/ui/dist'; +@source '../../../../common'; + +@theme inline { + --color-immich-primary: rgb(var(--immich-primary)); + --color-immich-primary-10: rgb(var(--immich-primary) / 0.1); +} diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/AlreadyCompleted.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/AlreadyCompleted.svelte new file mode 100644 index 00000000..5953626a --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/AlreadyCompleted.svelte @@ -0,0 +1,17 @@ + + +
+ + +

You've already completed this survey

+

Your responses were recorded. Thank you for your feedback!

+ + +
diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/EmailQuestion.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/EmailQuestion.svelte new file mode 100644 index 00000000..c2453301 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/EmailQuestion.svelte @@ -0,0 +1,40 @@ + + +

{question.text}

+ +{#if question.description} +

{question.description}

+{/if} + + diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/EmailSignupQuestion.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/EmailSignupQuestion.svelte new file mode 100644 index 00000000..5bcb4c22 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/EmailSignupQuestion.svelte @@ -0,0 +1,88 @@ + + +

{question.text}

+ +{#if question.description} +

{question.description}

+{/if} + + + +
+ {#each question.options ?? [] as option} + + {/each} +
+ +

+ If you only select the closed beta, we'll never email you except for the beta invite. +

diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/QuestionCard.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/QuestionCard.svelte new file mode 100644 index 00000000..9de651cd --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/QuestionCard.svelte @@ -0,0 +1,61 @@ + + +
+ {#if question.type === 'radio'} + + {:else if question.type === 'email'} + onAnswer(v)} /> + {:else if question.type === 'email-signup'} + + {:else if question.type === 'textarea'} + onAnswer(v)} /> + {/if} +
+ +
+
+ {#if canGoBack} + + {/if} + +
+
diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/RadioQuestion.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/RadioQuestion.svelte new file mode 100644 index 00000000..6d9def14 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/RadioQuestion.svelte @@ -0,0 +1,73 @@ + + +

{question.text}

+ +{#if question.description} +

{@html question.description}

+{/if} + +
+ {#each (question.options ?? []).filter((o) => !question.hasOther || o.value !== 'Other') as option} + + {/each} + + {#if question.hasOther} +
+ + {#if answer?.value === 'Other'} + + {/if} + + onAnswer('Other', otherText)} + oninput={() => onAnswer('Other', otherText)} + /> +
+ {/if} +
diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/SectionHeader.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/SectionHeader.svelte new file mode 100644 index 00000000..6e186fde --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/SectionHeader.svelte @@ -0,0 +1,41 @@ + + +
+
+ + Section {section.number} of {sections.length} + +

{section.title}

+ {#if section.description} +

{section.description}

+ {/if} +
+ {#if canGoBack && onBack} + + {/if} + + {#if onSkip} + + {/if} +
+
+
diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/SurveyShell.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/SurveyShell.svelte new file mode 100644 index 00000000..3e19a93f --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/SurveyShell.svelte @@ -0,0 +1,150 @@ + + +
+
+
+
+ +
+ {#key `${engine.currentIndex}-${showingSectionHeader}`} +
+ {#if showingSectionHeader && currentSection} + 1} + /> + {:else if engine.currentQuestion} +
+ handleAnswer(engine.currentQuestion!.id, value, otherText)} + onNext={handleNext} + onBack={handleBack} + canGoBack={engine.currentIndex > 0} + isLast={isLastQuestion} + /> +
+ {/if} +
+ {/key} +
+
diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/TextareaQuestion.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/TextareaQuestion.svelte new file mode 100644 index 00000000..2639bd79 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/src/lib/components/TextareaQuestion.svelte @@ -0,0 +1,44 @@ + + +

{question.text}

+ +{#if question.description} +

{question.description}

+{/if} + +