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

328 lines
16 KiB
Markdown

# Immich Survey Builder
A full-featured survey builder and response collection platform built with SvelteKit and Cloudflare Workers.
## Features
### Survey Builder
- **10 question types**: radio, checkbox, text, textarea, email, rating (stars), NPS (0-10), number, dropdown, likert scale
- **Skip logic**: show/hide questions based on prior answers (equals, notEquals, anyOf, skipped)
- **Drag-and-drop**: reorder questions and sections with drag handles or arrow buttons
- **Templates**: pre-built question templates (NPS, CSAT, demographics) and full survey templates (Customer Satisfaction, Event Feedback, Employee Engagement)
- **Bulk paste**: paste multiple options at once (one per line)
- **Preview mode**: live phone-frame preview without publishing
- **Undo/redo**: Ctrl+Z / Ctrl+Shift+Z with debounced snapshots
- **Scheduling**: auto-close at a date, limit max responses
- **Randomization**: randomize question and/or option order per respondent
- **Password protection**: optional password gate for survey access
- **Import/export**: portable JSON survey definitions
- **Archiving**: soft-archive surveys without deleting
### Response Experience
- **One-question-at-a-time**: animated transitions between questions
- **Mobile-optimized**: 44px+ touch targets, responsive NPS grid, safe-area-inset support
- **Keyboard navigation**: arrow keys for radio, Enter to advance
- **Inline validation**: required field errors shown next to the question
- **Resume support**: respondents can close and resume later via cookie-based sessions
### Analytics & Results
- **Real-time dashboard**: auto-refreshing results every 15 seconds
- **Bar & pie charts**: toggle between chart types per question
- **Timeline chart**: responses over time with day/hour granularity
- **Drop-off analysis**: per-question completion funnel
- **NPS score card**: promoter/passive/detractor segmented bar
- **Word cloud**: d3-cloud visualization for text responses
- **Individual response viewer**: paginated respondent list with expandable detail
- **Text search**: full-text search through open-ended answers
- **Cross-tabulation**: filter all results by answer to a specific question
- **Export**: CSV, JSON, and PDF report formats
- **Live counts**: active respondent tracking via Cloudflare Analytics Engine
### Sharing
- **Social sharing**: pre-formatted links for Twitter/X, LinkedIn, email
- **Copy link**: one-click URL copy
- **Embed**: iframe embed code for external sites
- **QR code**: scannable QR code for survey URLs
### Administration
- **Authentication**: password-based admin (default) + optional OIDC SSO
- **Role-based access**: admin, editor, viewer — synced from OIDC claims
- **Tags**: organize surveys with colored tags and dashboard filtering
- **Audit log**: track all admin actions with user, timestamp, and resource details
- **Survey duplication**: clone surveys with all sections and questions
## Architecture
```
Frontend (SvelteKit + Static Adapter) Backend (Cloudflare Worker)
├── src/routes/ Pages ├── src/routes/ API routes
├── src/lib/api/ API client ├── src/services/ Business logic
├── src/lib/engines/ State mgmt ├── src/repositories/ Data access
├── src/lib/components/ UI ├── src/middleware/ Auth
└── src/lib/stores/ Auth state └── src/utils/ Crypto
```
- **Database**: Cloudflare D1 (SQLite)
- **Analytics**: Cloudflare Analytics Engine (heartbeat tracking)
- **Auth**: Stateless JWT sessions (HMAC-SHA256)
## Setup
### Prerequisites
- Node.js 18+
- pnpm 10+
- Wrangler CLI (for Cloudflare Workers)
### Local Development
```bash
# Install dependencies
pnpm install
# Run database migrations
pnpm run db:migrate:local
# Start backend (port 8787)
cd backend && npx wrangler dev --port 8787
# Start frontend (port 5173, in another terminal)
pnpm run dev
```
On first visit to http://localhost:5173, you'll be prompted to set an admin password.
### Running Tests
```bash
pnpm vitest run # Unit tests
pnpm run check # TypeScript type checking
pnpm run lint # ESLint
pnpm run build # Production build
pnpm run test:e2e # Playwright E2E tests
```
## Configuration
### Environment Variables
All configuration is via environment variables in `backend/wrangler.jsonc`. For production, use Wrangler secrets (`wrangler secret put <NAME>`).
#### Required
| Variable | Description |
| ----------------- | ----------------------------------------------------------------------------------------- |
| `SESSION_SECRET` | Secret key for signing admin session JWTs. Use a random 32+ character string. |
| `PASSWORD_SECRET` | Secret key for survey password protection HMAC tokens. Use a random 32+ character string. |
#### OIDC Authentication (optional)
Configure these to enable SSO login alongside password authentication.
| Variable | Description | Example |
| ---------------------- | ------------------------------- | -------------------------------------------------- |
| `OIDC_ISSUER` | OIDC provider issuer URL | `https://auth.example.com/realms/immich` |
| `OIDC_CLIENT_ID` | Registered OIDC client ID | `survey-app` |
| `OIDC_CLIENT_SECRET` | OIDC client secret | (use `wrangler secret put`) |
| `OIDC_REDIRECT_URI` | Callback URL after login | `https://survey-api.example.com/api/auth/callback` |
| `OIDC_ROLE_CLAIM` | JWT claim path containing roles | `groups` or `realm_access.roles` |
| `OIDC_ROLE_MAP_ADMIN` | Claim value for admin role | `survey-admin` |
| `OIDC_ROLE_MAP_EDITOR` | Claim value for editor role | `survey-editor` |
#### Optional
| Variable | Description | Default |
| ----------------------- | --------------------------------------------------- | -------------------------- |
| `DISABLE_PASSWORD_AUTH` | Set to `true` to disable password login (OIDC only) | Not set (password enabled) |
### OIDC Configuration
The app supports any OIDC-compliant identity provider (Keycloak, Auth0, Okta, Azure AD, etc.).
#### Setup Steps
1. Register a new OIDC client in your identity provider
2. Set the redirect URI to `https://your-api-domain/api/auth/callback`
3. Configure the client for authorization code flow with `openid email profile` scopes
4. Set the environment variables listed above
5. Map your IdP's role/group claims to the survey app roles
#### Role Mapping
The app extracts roles from a configurable OIDC claim. Three roles are supported:
| Role | Permissions |
| ---------- | ------------------------------------------------------------------------------------------------- |
| **admin** | Full access: create, edit, delete, publish surveys; manage tags; view audit log; delete responses |
| **editor** | Create and edit surveys, publish/unpublish, manage tags, import/export |
| **viewer** | View surveys and results, export data |
The `OIDC_ROLE_CLAIM` supports nested paths for providers like Keycloak:
- Flat claim: `groups` → reads from `token.groups`
- Nested claim: `realm_access.roles` → reads from `token.realm_access.roles`
Users not matching any configured role value default to **viewer**.
#### Disabling Password Auth
Once OIDC is configured and working, you can disable password authentication entirely:
```bash
wrangler secret put DISABLE_PASSWORD_AUTH
# Enter: true
```
This hides the password login form and rejects password login API calls. Only OIDC login will be available.
### Cloudflare Bindings
| Binding | Type | Description |
| ----------- | ------------------------ | ----------------------------------------- |
| `DB` | D1 Database | Primary data store |
| `ANALYTICS` | Analytics Engine Dataset | Heartbeat tracking for live viewer counts |
### Database
The app uses Cloudflare D1 (SQLite). Migrations are in `backend/migrations/` and applied with:
```bash
# Local
pnpm run db:migrate:local
# Production
cd backend && npx wrangler d1 migrations apply survey --remote
```
#### Tables
| Table | Purpose |
| ------------------- | ------------------------------------------------------- |
| `surveys` | Survey metadata, status, scheduling, password |
| `survey_sections` | Ordered sections within a survey |
| `survey_questions` | Questions with type, options, config, conditional logic |
| `respondents` | Survey respondent sessions |
| `answers` | Individual question responses |
| `tags` | Survey tags for organization |
| `survey_tags` | Survey-to-tag associations |
| `audit_log` | Admin action audit trail |
| `admin_credentials` | Local admin password hash |
## API Reference
### Authentication
| Method | Path | Description |
| ------ | -------------------------- | --------------------------------- |
| `GET` | `/api/auth/me` | Check auth status and setup state |
| `POST` | `/api/auth/setup` | First-time admin password setup |
| `POST` | `/api/auth/password-login` | Password login |
| `GET` | `/api/auth/login` | OIDC login redirect |
| `GET` | `/api/auth/callback` | OIDC callback |
| `POST` | `/api/auth/logout` | Clear session |
### Surveys (requires auth)
| Method | Path | Description |
| -------- | ----------------------------- | -------------------------------------- |
| `GET` | `/api/surveys?archived=true` | List surveys |
| `POST` | `/api/surveys` | Create survey |
| `GET` | `/api/surveys/:id` | Get survey with sections and questions |
| `PUT` | `/api/surveys/:id` | Update survey |
| `DELETE` | `/api/surveys/:id` | Delete survey |
| `PUT` | `/api/surveys/:id/publish` | Publish |
| `PUT` | `/api/surveys/:id/unpublish` | Unpublish |
| `POST` | `/api/surveys/:id/duplicate` | Duplicate |
| `PUT` | `/api/surveys/:id/archive` | Archive |
| `PUT` | `/api/surveys/:id/unarchive` | Unarchive |
| `GET` | `/api/surveys/:id/definition` | Export definition |
| `POST` | `/api/surveys/import` | Import definition |
### Sections & Questions (requires auth)
| Method | Path | Description |
| -------- | ------------------------------------- | ----------------- |
| `POST` | `/api/surveys/:id/sections` | Create section |
| `PUT` | `/api/sections/:id` | Update section |
| `DELETE` | `/api/sections/:id` | Delete section |
| `PUT` | `/api/surveys/:id/sections/reorder` | Reorder sections |
| `POST` | `/api/sections/:id/questions` | Create question |
| `PUT` | `/api/questions/:id` | Update question |
| `DELETE` | `/api/questions/:id` | Delete question |
| `PUT` | `/api/sections/:id/questions/reorder` | Reorder questions |
### Tags (requires auth)
| Method | Path | Description |
| -------- | ----------------------- | --------------- |
| `GET` | `/api/tags` | List all tags |
| `POST` | `/api/tags` | Create tag |
| `PUT` | `/api/tags/:id` | Update tag |
| `DELETE` | `/api/tags/:id` | Delete tag |
| `GET` | `/api/surveys/:id/tags` | Get survey tags |
| `PUT` | `/api/surveys/:id/tags` | Set survey tags |
### Results (requires auth)
| Method | Path | Description |
| -------- | --------------------------------------------------------- | ---------------------------------- |
| `GET` | `/api/surveys/:id/results` | Aggregated results |
| `GET` | `/api/surveys/:id/results/live` | Real-time results with live counts |
| `GET` | `/api/surveys/:id/results/timeline?granularity=day\|hour` | Response timeline |
| `GET` | `/api/surveys/:id/results/dropoff` | Drop-off analysis |
| `GET` | `/api/surveys/:id/results/respondents?offset=0&limit=20` | List respondents |
| `GET` | `/api/surveys/:id/results/respondents/:rid` | Respondent detail |
| `DELETE` | `/api/surveys/:id/results/respondents/:rid` | Delete respondent |
| `GET` | `/api/surveys/:id/results/search?q=term` | Search text answers |
| `GET` | `/api/surveys/:id/results/export?format=csv\|json` | Export responses |
### Public Survey Routes (no auth)
| Method | Path | Description |
| ------ | ---------------------------- | --------------------------------- |
| `GET` | `/api/s/:slug` | Get published survey |
| `POST` | `/api/s/:slug/auth` | Authenticate with survey password |
| `GET` | `/api/s/:slug/resume` | Resume survey session |
| `POST` | `/api/s/:slug/answers/batch` | Submit answers |
| `POST` | `/api/s/:slug/complete` | Complete survey |
| `POST` | `/api/s/:slug/heartbeat` | Analytics heartbeat |
### Audit Log (requires admin)
| Method | Path | Description |
| ------ | ---------------------------------- | -------------------------- |
| `GET` | `/api/audit-log?offset=0&limit=50` | List audit entries |
| `GET` | `/api/audit-log/survey/:id` | Audit entries for a survey |
## Question Types
| Type | Description | Options | Config |
| ---------- | --------------------- | ------------- | --------------------------------------- |
| `radio` | Single choice | Required (2+) | — |
| `checkbox` | Multiple choice | Required (2+) | — |
| `text` | Short text input | — | placeholder |
| `textarea` | Long text input | — | maxLength, placeholder |
| `email` | Email with validation | — | placeholder |
| `rating` | Star rating | — | scaleMax (5 or 10), lowLabel, highLabel |
| `nps` | Net Promoter Score | — | scaleMax (10) |
| `number` | Numeric input | — | min, max |
| `dropdown` | Select from list | Required (2+) | — |
| `likert` | Agreement scale | — | scaleMax, lowLabel, highLabel |
All question types support: required/optional, description text, skip logic (conditional visibility), and "allow other" option (for radio/checkbox/dropdown).
## Technology Stack
- **Frontend**: SvelteKit 2, Svelte 5, TypeScript, Tailwind CSS 4, @immich/ui
- **Backend**: Cloudflare Workers, itty-router, D1 (SQLite)
- **Charts**: Chart.js (bar, pie, line), d3-cloud (word cloud)
- **PDF**: jsPDF
- **Auth**: OIDC, PBKDF2 (Web Crypto API), HMAC-SHA256 JWTs
- **DnD**: svelte-dnd-action
- **Testing**: Vitest, Playwright