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>
This commit is contained in:
Zack Pollard
2026-08-15 11:18:16 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Zack Pollard
parent cb47e2344a
commit f4b32da07a
214 changed files with 31512 additions and 13 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
strategy:
fail-fast: false
matrix:
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'survey']
env:
TF_VAR_app_name: ${{ matrix.app_name }}
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
# Keep app_name in sync with the deploy matrix in build.yml.
matrix:
environment: ['dev', 'prod']
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'survey']
env:
ENVIRONMENT: ${{ matrix.environment }}
TF_VAR_app_name: ${{ matrix.app_name }}
+80
View File
@@ -75,3 +75,83 @@ jobs:
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
survey-e2e:
name: Test (survey e2e)
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/survey.immich.app
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- id: token
uses: immich-app/devtools/actions/create-workflow-token@1af396ae134e4bc3b63d947e672bc68bf4ff9dc5 # create-workflow-token-action-v3.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@3bca63ca3c15020293b36b51737a3ee2c773340b # use-mise-action-v3.1.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Get pnpm store directory
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Run install
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
if: ${{ !cancelled() }}
- name: Start wrangler and Vite
# Boot both servers in the background and wait for readiness. /api/auth/me
# answers 200 once wrangler is up (with {authenticated:false}); match on
# that so we don't race ahead of a still-starting server.
if: ${{ !cancelled() }}
run: |
(cd backend && npx wrangler d1 migrations apply survey --local --config wrangler-dev.jsonc)
(cd backend && npx wrangler dev --config wrangler-dev.jsonc --port 8787) &
echo "WRANGLER_PID=$!" >> $GITHUB_ENV
pnpm dev &
echo "VITE_PID=$!" >> $GITHUB_ENV
timeout 60 bash -c 'until [ "$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8787/api/auth/me)" = "200" ]; do sleep 1; done'
timeout 60 bash -c 'until curl -sf http://localhost:5173 >/dev/null 2>&1; do sleep 1; done'
- name: Backend integration tests
if: ${{ !cancelled() }}
working-directory: apps/survey.immich.app/backend
run: pnpm test:integration
- name: E2E tests
if: ${{ !cancelled() }}
run: pnpm exec playwright test
- name: Stop dev servers
if: ${{ always() }}
run: |
if [ -n "${WRANGLER_PID:-}" ]; then kill "$WRANGLER_PID" 2>/dev/null || true; fi
if [ -n "${VITE_PID:-}" ]; then kill "$VITE_PID" 2>/dev/null || true; fi
- name: Upload Playwright report
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: apps/survey.immich.app/test-results/
retention-days: 7
+4
View File
@@ -32,3 +32,7 @@ vite.config.ts.timestamp-*
.planning/
.claude/
apps/*/.planning
dist/
# Dogfood artifacts (screenshots, videos, reports produced by the dogfood skill)
dogfood-output/
+29
View File
@@ -0,0 +1,29 @@
# === Required ===
SESSION_SECRET= # Random 64+ char string for signing session JWTs
PASSWORD_SECRET= # Random 64+ char string for survey password tokens
# === Database ===
# SQLite (default): path to .db file
DATABASE_URL=/data/survey.db
# PostgreSQL: uncomment and set connection string
# DATABASE_URL=postgresql://survey:survey@postgres:5432/survey
# === Server ===
PORT=3000
COOKIE_SECURE=false # IMPORTANT: Set to true in production behind HTTPS reverse proxy
# When false, session cookies work over HTTP (dev only)
# === OIDC (optional — leave empty for password-only auth) ===
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=
OIDC_ROLE_CLAIM=groups
OIDC_ROLE_MAP_ADMIN=survey-admin
OIDC_ROLE_MAP_EDITOR=survey-editor
# === Optional ===
DISABLE_PASSWORD_AUTH=false # Set to true to disable password login (OIDC only)
ADMIN_SETUP_TOKEN=
STATIC_DIR= # Path to static frontend build (auto-set in Docker)
+4
View File
@@ -0,0 +1,4 @@
backend/.wrangler
test-results
playwright-report
dogfood-output
+50
View File
@@ -0,0 +1,50 @@
FROM node:20-slim AS frontend-builder
# pnpm version is pinned via the root package.json "packageManager" field
ENV COREPACK_ENABLE_STRICT=1
RUN corepack enable
WORKDIR /app
# pnpm --frozen-lockfile fails unless every package.json referenced by
# pnpm-workspace.yaml exists, hence the unrelated app manifests below.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/survey.immich.app/package.json apps/survey.immich.app/
COPY apps/survey.immich.app/backend/package.json apps/survey.immich.app/backend/
COPY apps/datasets.immich.app/backend/package.json apps/datasets.immich.app/backend/
COPY apps/futo-backups-survey.immich.app/backend/package.json apps/futo-backups-survey.immich.app/backend/
COPY common/ common/
RUN pnpm install --frozen-lockfile --filter survey...
COPY apps/survey.immich.app/ apps/survey.immich.app/
RUN cd apps/survey.immich.app && pnpm run build
FROM node:20-slim AS backend-builder
ENV COREPACK_ENABLE_STRICT=1
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/survey.immich.app/package.json apps/survey.immich.app/
COPY apps/survey.immich.app/backend/package.json apps/survey.immich.app/backend/
COPY apps/datasets.immich.app/backend/package.json apps/datasets.immich.app/backend/
COPY apps/futo-backups-survey.immich.app/backend/package.json apps/futo-backups-survey.immich.app/backend/
RUN pnpm install --frozen-lockfile --filter survey-backend...
COPY apps/survey.immich.app/backend/ apps/survey.immich.app/backend/
RUN cd apps/survey.immich.app/backend && npx esbuild src/server.ts --bundle --platform=node --format=esm --outdir=dist --external:better-sqlite3 --external:pg
FROM node:20-slim
ENV COREPACK_ENABLE_STRICT=1
RUN corepack enable
WORKDIR /app
COPY --from=frontend-builder /app/apps/survey.immich.app/build /app/public
COPY --from=backend-builder /app/apps/survey.immich.app/backend/dist /app/dist
COPY --from=backend-builder /app/apps/survey.immich.app/backend/migrations /app/migrations
COPY --from=backend-builder /app/apps/survey.immich.app/backend/package.json /app/
RUN pnpm install --prod
ENV PORT=3000
ENV STATIC_DIR=/app/public
ENV DATABASE_URL=/data/survey.db
EXPOSE 3000
VOLUME /data
CMD ["node", "dist/server.js"]
+327
View File
@@ -0,0 +1,327 @@
# 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
@@ -0,0 +1,2 @@
data/
.svelte-kit/
@@ -0,0 +1,125 @@
-- surveys
CREATE TABLE surveys (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
slug TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'draft',
welcome_title TEXT,
welcome_description TEXT,
thank_you_title TEXT,
thank_you_description TEXT,
closes_at TEXT,
max_responses INTEGER,
randomize_questions INTEGER DEFAULT 0,
randomize_options INTEGER DEFAULT 0,
password_hash TEXT,
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_surveys_slug ON surveys(slug);
-- survey sections
CREATE TABLE survey_sections (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL,
FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE
);
CREATE INDEX idx_sections_survey ON survey_sections(survey_id);
-- survey questions
CREATE TABLE survey_questions (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
section_id TEXT NOT NULL,
text TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL,
options TEXT,
required INTEGER NOT NULL DEFAULT 1,
has_other INTEGER NOT NULL DEFAULT 0,
other_prompt TEXT,
max_length INTEGER,
placeholder TEXT,
sort_order INTEGER NOT NULL,
conditional TEXT,
config TEXT,
FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE,
FOREIGN KEY (section_id) REFERENCES survey_sections(id) ON DELETE CASCADE
);
CREATE INDEX idx_questions_survey ON survey_questions(survey_id);
CREATE INDEX idx_questions_section ON survey_questions(section_id);
-- respondents
CREATE TABLE respondents (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
ip_address TEXT,
is_complete INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (survey_id) REFERENCES surveys(id)
);
CREATE INDEX idx_respondents_survey ON respondents(survey_id);
-- answers
CREATE TABLE answers (
respondent_id TEXT NOT NULL,
question_id TEXT NOT NULL,
answer TEXT NOT NULL,
other_text TEXT,
answered_at TEXT NOT NULL,
PRIMARY KEY (respondent_id, question_id),
FOREIGN KEY (respondent_id) REFERENCES respondents(id)
);
-- audit log
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
user_sub TEXT NOT NULL,
user_email TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
details TEXT,
ip_address TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_audit_log_user ON audit_log(user_sub);
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);
-- tags
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE survey_tags (
survey_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (survey_id, tag_id),
FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX idx_survey_tags_survey ON survey_tags(survey_id);
CREATE INDEX idx_survey_tags_tag ON survey_tags(tag_id);
-- admin credentials
CREATE TABLE admin_credentials (
id TEXT PRIMARY KEY DEFAULT 'default',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
@@ -0,0 +1,4 @@
-- Per-question timing: how long (in milliseconds) a respondent spent on
-- each question between it becoming visible and them committing the answer.
-- Nullable so existing rows don't need backfilling.
ALTER TABLE answers ADD COLUMN answer_ms INTEGER;
@@ -0,0 +1,36 @@
{
"name": "survey-backend",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types",
"build": "pnpm build:workers",
"build:workers": "wrangler build --config wrangler-do.jsonc && mv dist/index.js dist/sessions.js && wrangler build",
"start": "tsx src/server.ts",
"build:node": "esbuild src/server.ts --bundle --platform=node --format=esm --outdir=dist --external:better-sqlite3 --external:pg",
"load-test": "tsx scripts/load-test.ts",
"test:integration": "vitest run",
"test:integration:watch": "vitest"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260416.2",
"@hono/node-server": "^1.19.12",
"@types/better-sqlite3": "^7.6.13",
"@types/pg": "^8.20.0",
"@types/ws": "^8.18.1",
"esbuild": "^0.27.4",
"hono": "^4.12.9",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"vitest": "^4.1.0",
"wrangler": "^4.75.0",
"ws": "^8.20.0"
},
"dependencies": {
"itty-router": "^5.0.18",
"better-sqlite3": "^12.8.0",
"pg": "^8.20.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
/**
* Memory profiler for the SurveyDO cache.
*
* Simulates the in-memory state the DO would hold at various load levels and
* measures actual V8 heap usage via process.memoryUsage().
*
* Usage: tsx scripts/memory-profile.ts
*/
interface RespondentState {
isComplete: boolean;
hasSubmitted: boolean;
choiceAnswers: Map<string, { value: string; otherText: string | null }>;
}
const CHOICE_TYPES = new Set(['radio', 'checkbox', 'dropdown', 'rating', 'nps', 'likert']);
interface AnswerTally {
value: string;
otherText: string | null;
count: number;
}
interface SurveyRow {
id: string;
title: string;
description: string | null;
slug: string | null;
status: string;
welcome_title: string | null;
welcome_description: string | null;
thank_you_title: string | null;
thank_you_description: string | null;
closes_at: string | null;
max_responses: number | null;
randomize_questions: number;
randomize_options: number;
password_hash: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
}
interface SectionRow {
id: string;
survey_id: string;
title: string;
description: string | null;
sort_order: number;
}
interface QuestionRow {
id: string;
survey_id: string;
section_id: string;
text: string;
description: string | null;
type: string;
options: string | null;
required: number;
has_other: number;
other_prompt: string | null;
max_length: number | null;
placeholder: string | null;
sort_order: number;
conditional: string | null;
config: string | null;
}
function uuid(): string {
return [8, 4, 4, 4, 12]
.map((n) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join(''))
.join('-');
}
function generateSurvey(): SurveyRow {
return {
id: uuid(),
title: 'Sample Survey — Customer Feedback',
description: 'A survey to gather customer feedback about our products and services.',
slug: 'customer-feedback',
status: 'published',
welcome_title: 'Welcome to our feedback survey',
welcome_description: 'Thank you for taking the time to share your thoughts. This should take about 5 minutes.',
thank_you_title: 'Thank you for your response!',
thank_you_description: 'We appreciate your feedback and will use it to improve our products.',
closes_at: null,
max_responses: null,
randomize_questions: 0,
randomize_options: 0,
password_hash: null,
archived_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
function generateSections(count: number, surveyId: string): SectionRow[] {
return Array.from({ length: count }, (_, i) => ({
id: uuid(),
survey_id: surveyId,
title: `Section ${i + 1}: Some topic here`,
description: 'A brief description of what this section covers and why it matters.',
sort_order: i,
}));
}
const QUESTION_TYPES = [
'radio',
'checkbox',
'text',
'textarea',
'email',
'rating',
'nps',
'number',
'dropdown',
'likert',
];
function generateQuestions(count: number, surveyId: string, sections: SectionRow[]): QuestionRow[] {
return Array.from({ length: count }, (_, i) => {
const type = QUESTION_TYPES[i % QUESTION_TYPES.length];
const section = sections[i % sections.length];
const hasOptions = ['radio', 'checkbox', 'dropdown'].includes(type);
const options = hasOptions
? JSON.stringify([
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
{ label: 'Option 3', value: 'opt3' },
{ label: 'Option 4', value: 'opt4' },
])
: null;
return {
id: uuid(),
survey_id: surveyId,
section_id: section.id,
text: `Question ${i + 1}: What do you think about our product feature?`,
description: 'Please provide your honest feedback based on your experience.',
type,
options,
required: 1,
has_other: 0,
other_prompt: null,
max_length: type === 'textarea' ? 500 : null,
placeholder: 'Enter your response here',
sort_order: i,
conditional: null,
config: null,
};
});
}
function generateAnswer(
question: QuestionRow,
fillLevel: 'short' | 'medium' | 'long',
): { value: string; otherText: string | null } {
switch (question.type) {
case 'text':
return { value: 'John Doe', otherText: null };
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':
return { value: 'opt2', otherText: null };
case 'checkbox':
return { value: 'opt1,opt3', otherText: null };
case 'rating':
return { value: '4', otherText: null };
case 'nps':
return { value: '8', otherText: null };
case 'number':
return { value: '42', otherText: null };
case 'likert':
return { value: 'Agree', otherText: null };
default:
return { value: 'test', otherText: null };
}
}
function generateRespondentState(
questions: QuestionRow[],
answerCount: number,
fillLevel: 'short' | 'medium' | 'long',
): RespondentState {
// Matches the real cache: only keep choice answers in memory
const choiceAnswers = new Map<string, { value: string; otherText: string | null }>();
const actualCount = Math.min(answerCount, questions.length);
for (let i = 0; i < actualCount; i++) {
if (CHOICE_TYPES.has(questions[i].type)) {
choiceAnswers.set(questions[i].id, generateAnswer(questions[i], fillLevel));
}
}
return { isComplete: false, hasSubmitted: actualCount > 0, choiceAnswers };
}
function generateTallies(questions: QuestionRow[], uniqueValuesPerQuestion: number): Map<string, AnswerTally[]> {
const tallies = new Map<string, AnswerTally[]>();
const choiceTypes = new Set(['radio', 'checkbox', 'dropdown', 'rating', 'nps', 'likert']);
for (const q of questions) {
if (!choiceTypes.has(q.type)) continue;
const tally: AnswerTally[] = [];
for (let i = 0; i < uniqueValuesPerQuestion; i++) {
tally.push({ value: `value_${i}`, otherText: null, count: Math.floor(Math.random() * 1000) });
}
tallies.set(q.id, tally);
}
return tallies;
}
function measure<T>(label: string, setup: () => T): { label: string; result: T; bytes: number } {
if (global.gc) global.gc();
const before = process.memoryUsage().heapUsed;
const result = setup();
if (global.gc) global.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`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
interface Scenario {
name: string;
questionCount: number;
sectionCount: number;
fillLevel: 'short' | 'medium' | 'long';
}
const SCENARIOS: Scenario[] = [
{ name: 'Small (10q, short answers)', questionCount: 10, sectionCount: 3, fillLevel: 'short' },
{ name: 'Medium (20q, medium answers)', questionCount: 20, sectionCount: 5, fillLevel: 'medium' },
{ name: 'Large (50q, long textareas)', questionCount: 50, sectionCount: 10, fillLevel: 'long' },
];
const CONCURRENT_COUNTS = [100, 1000, 5000, 10000];
function profileScenario(scenario: Scenario) {
console.log(`\n${'='.repeat(70)}`);
console.log(`Scenario: ${scenario.name}`);
console.log('='.repeat(70));
const staticMeasure = measure('Static cache (survey + sections + questions + tallies)', () => {
const survey = generateSurvey();
const sections = generateSections(scenario.sectionCount, survey.id);
const questions = generateQuestions(scenario.questionCount, survey.id, sections);
const tallies = generateTallies(questions, 10);
const counters = { total: 0, completed: 0 };
return { survey, sections, questions, tallies, counters };
});
console.log(
`\n Static data (survey + ${scenario.sectionCount} sections + ${scenario.questionCount} questions + tallies):`,
);
console.log(` ${fmt(staticMeasure.bytes)}`);
// Keep reference so GC doesn't collect
const questions = staticMeasure.result.questions;
const singleRespondent = measure('Single respondent state (all answers)', () => {
return generateRespondentState(questions, scenario.questionCount, scenario.fillLevel);
});
console.log(`\n Per-respondent state (all ${scenario.questionCount} questions answered):`);
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'}`);
for (const count of CONCURRENT_COUNTS) {
const concurrent = measure(`${count} concurrent`, () => {
const states = new Map<string, RespondentState>();
for (let i = 0; i < count; i++) {
// Mix: 30% just started, 40% halfway, 30% almost done
const progress = i % 10;
const answerCount =
progress < 3
? Math.floor(scenario.questionCount * 0.1)
: progress < 7
? Math.floor(scenario.questionCount * 0.5)
: Math.floor(scenario.questionCount * 0.9);
states.set(uuid(), generateRespondentState(questions, answerCount, scenario.fillLevel));
}
return states;
});
const perUser = concurrent.bytes / count;
const pctOf100MB = ((concurrent.bytes / (100 * 1024 * 1024)) * 100).toFixed(1);
console.log(
` ${String(count).padEnd(10)} ${fmt(concurrent.bytes).padEnd(15)} ${fmt(perUser).padEnd(15)} ${pctOf100MB}%`,
);
}
}
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) {
console.log('\n⚠ For accurate measurements, run with: node --expose-gc --import tsx scripts/memory-profile.ts');
}
for (const scenario of SCENARIOS) {
profileScenario(scenario);
}
console.log('\n' + '='.repeat(70));
console.log('Notes:');
console.log(' - Static cache is a fixed cost per DO (one-time).');
console.log(' - Per-user state is evicted on complete() — only in-progress users count.');
console.log(' - WebSocket hibernation state is not included (managed by Cloudflare).');
console.log(' - Measurements include V8 object/Map overhead.');
console.log('='.repeat(70));
@@ -0,0 +1,73 @@
import type { Kysely } from 'kysely';
import type { IRequest } from 'itty-router';
import type { Database } from './db';
export interface AppConfig {
passwordSecret: string;
sessionSecret: string;
oidc: {
issuer: string;
clientId: string;
clientSecret: string;
redirectUri: string;
roleClaim: string;
roleMapAdmin: string;
roleMapEditor: string;
};
disablePasswordAuth: boolean;
cookieSecure: boolean;
setupToken: string;
}
export interface AppContext {
db: Kysely<Database>;
config: AppConfig;
}
export function configFromEnv(env: Env): AppConfig {
return {
passwordSecret: env.PASSWORD_SECRET ?? '',
sessionSecret: env.SESSION_SECRET ?? '',
oidc: {
issuer: env.OIDC_ISSUER ?? '',
clientId: env.OIDC_CLIENT_ID ?? '',
clientSecret: env.OIDC_CLIENT_SECRET ?? '',
redirectUri: env.OIDC_REDIRECT_URI ?? '',
roleClaim: env.OIDC_ROLE_CLAIM ?? 'groups',
roleMapAdmin: env.OIDC_ROLE_MAP_ADMIN ?? 'survey-admin',
roleMapEditor: env.OIDC_ROLE_MAP_EDITOR ?? 'survey-editor',
},
disablePasswordAuth: env.DISABLE_PASSWORD_AUTH === 'true',
setupToken: env.ADMIN_SETUP_TOKEN ?? '',
cookieSecure: true, // Always true on Workers (HTTPS)
};
}
export function configFromProcessEnv(): AppConfig {
const passwordSecret = process.env.PASSWORD_SECRET ?? '';
const sessionSecret = process.env.SESSION_SECRET ?? '';
// Fail fast: an empty signing key doesn't error, it silently breaks all auth.
if (!passwordSecret || !sessionSecret) {
throw new Error('PASSWORD_SECRET and SESSION_SECRET must both be set to non-empty values.');
}
return {
passwordSecret,
sessionSecret,
oidc: {
issuer: process.env.OIDC_ISSUER ?? '',
clientId: process.env.OIDC_CLIENT_ID ?? '',
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
redirectUri: process.env.OIDC_REDIRECT_URI ?? '',
roleClaim: process.env.OIDC_ROLE_CLAIM ?? 'groups',
roleMapAdmin: process.env.OIDC_ROLE_MAP_ADMIN ?? 'survey-admin',
roleMapEditor: process.env.OIDC_ROLE_MAP_EDITOR ?? 'survey-editor',
},
disablePasswordAuth: process.env.DISABLE_PASSWORD_AUTH === 'true',
setupToken: process.env.ADMIN_SETUP_TOKEN ?? '',
cookieSecure: process.env.COOKIE_SECURE !== 'false',
};
}
export function getContext(request: IRequest): AppContext {
return (request as any).ctx as AppContext;
}
@@ -0,0 +1,75 @@
export const VALID_QUESTION_TYPES = [
'radio',
'checkbox',
'text',
'textarea',
'email',
'rating',
'nps',
'number',
'dropdown',
'likert',
];
export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/;
// Single source of truth lives in the shared protocol module so the client
// chunks its answer flushes to exactly the size the server accepts.
export { BATCH_ANSWER_LIMIT } from '../../shared/ws-protocol';
export const ACTIVE_RESPONDENT_WINDOW_MS = 5 * 60 * 1000;
export const SEARCH_RESULT_LIMIT = 100;
export const MAX_PAGINATION_LIMIT = 100;
export const PASSWORD_SESSION_MAX_AGE = 24 * 60 * 60;
export const PBKDF2_ITERATIONS = 100_000;
export const SESSION_MAX_AGE = 8 * 60 * 60;
export const SESSION_COOKIE_NAME = 'survey_session';
export const AUTH_STATE_COOKIE_NAME = 'auth_state';
export type UserRole = 'admin' | 'editor' | 'viewer';
export const ROLE_HIERARCHY: Record<string, number> = { admin: 3, editor: 2, viewer: 1, public: 0 };
/**
* Cap on client-reported time-on-question: anything longer is a tab left open
* overnight or a broken clock, not real engagement.
*/
export const MAX_ANSWER_MS = 24 * 60 * 60 * 1000;
/** 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;
return Math.min(Math.floor(raw), MAX_ANSWER_MS);
}
/**
* Fast-tier broadcast interval (presence, counters, in-memory choice results).
* Slow-tier fires every SLOW_TICKS_PER_CYCLE × fast ticks and runs the
* SQL-backed analytics queries.
*/
export const BROADCAST_FAST_INTERVAL_MS = 5000;
export const BROADCAST_SLOW_TICKS_PER_CYCLE = 12; // 12 × 5s = 60s
export const COMPLETION_TIME_BUCKETS: ReadonlyArray<{
label: string;
minSeconds: number;
maxSeconds: number | null;
}> = [
{ label: '<30s', minSeconds: 0, maxSeconds: 30 },
{ label: '30s–1m', minSeconds: 30, maxSeconds: 60 },
{ label: '1–2m', minSeconds: 60, maxSeconds: 120 },
{ label: '2–5m', minSeconds: 120, maxSeconds: 300 },
{ label: '5–10m', minSeconds: 300, maxSeconds: 600 },
{ label: '10–30m', minSeconds: 600, maxSeconds: 1800 },
{ label: '30m–1h', minSeconds: 1800, maxSeconds: 3600 },
{ label: '>1h', minSeconds: 3600, maxSeconds: null },
];
/** 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;
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((p / 100) * (sorted.length - 1))));
return sorted[idx];
}
@@ -0,0 +1,33 @@
const COOKIE_MAX_AGE = 60 * 60 * 24 * 90;
export function getCookie(
request: { headers: { get(name: string): string | null } },
name: string,
): string | undefined {
const header = request.headers.get('Cookie') ?? '';
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return match?.[1];
}
export function getCookieName(slug: string): string {
return `rid_${slug}`;
}
export function getRespondentId(request: Request, slug: string): string | undefined {
return getCookie(request, getCookieName(slug));
}
export function setRespondentCookie(headers: Headers, slug: string, respondentId: string, secure = true): void {
const cookieName = getCookieName(slug);
const secureFlag = secure ? 'Secure; ' : '';
headers.set(
'Set-Cookie',
`${cookieName}=${respondentId}; Path=/; HttpOnly; ${secureFlag}SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`,
);
}
export function deleteRespondentCookie(headers: Headers, slug: string, secure = true): void {
const cookieName = getCookieName(slug);
const secureFlag = secure ? 'Secure; ' : '';
headers.set('Set-Cookie', `${cookieName}=; Path=/; HttpOnly; ${secureFlag}SameSite=Lax; Max-Age=0`);
}
+141
View File
@@ -0,0 +1,141 @@
import { Kysely } from 'kysely';
import { CloudflareD1Dialect } from '@immich/kysely-adapter-cloudflare';
import type { Selectable } from 'kysely';
export interface SurveysTable {
id: string;
title: string;
description: string | null;
slug: string | null;
status: string;
welcome_title: string | null;
welcome_description: string | null;
thank_you_title: string | null;
thank_you_description: string | null;
closes_at: string | null;
max_responses: number | null;
randomize_questions: number;
randomize_options: number;
password_hash: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
}
export interface SurveySectionsTable {
id: string;
survey_id: string;
title: string;
description: string | null;
sort_order: number;
}
export interface SurveyQuestionsTable {
id: string;
survey_id: string;
section_id: string;
text: string;
description: string | null;
type: string;
options: string | null;
required: number;
has_other: number;
other_prompt: string | null;
max_length: number | null;
placeholder: string | null;
sort_order: number;
conditional: string | null;
config: string | null;
}
export interface RespondentsTable {
id: string;
survey_id: string;
ip_address: string | null;
is_complete: number;
created_at: string;
completed_at: string | null;
}
export interface AnswersTable {
respondent_id: string;
question_id: string;
answer: string;
other_text: string | null;
answered_at: string;
/** Milliseconds the respondent spent on this question before committing. */
answer_ms: number | null;
}
export interface TagsTable {
id: string;
name: string;
color: string | null;
created_at: string;
}
export interface SurveyTagsTable {
survey_id: string;
tag_id: string;
}
export interface AuditLogTable {
id: string;
user_sub: string;
user_email: string;
action: string;
resource_type: string;
resource_id: string | null;
details: string | null;
ip_address: string | null;
created_at: string;
}
export interface AdminCredentialsTable {
id: string;
password_hash: string;
created_at: string;
}
export interface Database {
surveys: SurveysTable;
survey_sections: SurveySectionsTable;
survey_questions: SurveyQuestionsTable;
respondents: RespondentsTable;
answers: AnswersTable;
tags: TagsTable;
survey_tags: SurveyTagsTable;
audit_log: AuditLogTable;
admin_credentials: AdminCredentialsTable;
}
export type SurveyRow = Selectable<SurveysTable>;
export type SectionRow = Selectable<SurveySectionsTable>;
export type QuestionRow = Selectable<SurveyQuestionsTable>;
export type RespondentRow = Selectable<RespondentsTable>;
export type AnswerRow = Selectable<AnswersTable>;
export type TagRow = Selectable<TagsTable>;
export type AuditLogRow = Selectable<AuditLogTable>;
export type DbType = 'd1' | 'sqlite' | 'postgres';
export interface DbConfig {
type: DbType;
url?: string;
d1?: D1Database;
}
export function detectDbType(url?: string): DbType {
if (!url) return 'sqlite';
if (url.startsWith('postgres://') || url.startsWith('postgresql://')) return 'postgres';
return 'sqlite';
}
export function createD1Database(d1: D1Database): Kysely<Database> {
return new Kysely<Database>({
dialect: new CloudflareD1Dialect({ database: d1 as unknown as import('@cloudflare/workers-types').D1Database }),
});
}
// Async multi-dialect factory is in server.ts (Node.js only)
// to avoid bundling better-sqlite3/pg into the Workers build
@@ -0,0 +1,5 @@
// Local dev / CI entry point: the worker and the DO ship in one worker so there
// is no cross-service DO binding to configure.
export { SurveyDO } from './durable-objects/survey-do';
export { default } from './index';
@@ -0,0 +1,258 @@
/**
* In-memory cache for the SurveyDO. Cleared whenever the DO hibernates or is
* evicted, so every field must be rebuildable from SQLite on demand.
* Reads bypass Kysely (direct SqlStorage) for speed.
*/
import type { SurveyRow, SectionRow, QuestionRow } from '../db';
import { ServiceError } from '../services/errors';
const CHOICE_TYPES = new Set(['radio', 'checkbox', 'dropdown', 'rating', 'nps', 'likert']);
export interface AnswerTally {
value: string;
otherText: string | null;
count: number;
}
/**
* Minimal per-respondent state held in memory during a session. Only choice
* answers are cached (updateTalliesOnCompletion needs them; text answers are never
* tallied), keeping per-user memory bounded by the choice-question count rather
* than by how much text the user types.
*/
export interface RespondentState {
isComplete: boolean;
hasSubmitted: boolean;
choiceAnswers: Map<string, { value: string; otherText: string | null }>;
}
export class SurveyCache {
private _survey: SurveyRow | null = null;
private _sections: SectionRow[] | null = null;
private _questions: QuestionRow[] | null = null;
private _counters: { total: number; completed: number } | null = null;
private _tallies: Map<string, AnswerTally[]> | null = null;
private _choiceQuestionIds: Set<string> | null = null;
/**
* Per-respondent state for active sessions. Populated on upgrade (new respondents
* start empty) and lazily on resume (returning respondents load from SQL once).
* Evicted on complete or hibernation. Avoids SQL hits for submit-answers and
* updateTalliesOnCompletion since the choice answers are already in memory.
*/
private _respondentState = new Map<string, RespondentState>();
/** 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). */
fastTick = 0;
constructor(private sql: SqlStorage) {}
hasRespondent(id: string): boolean {
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;
}
initRespondent(id: string): void {
this._respondentState.set(id, { isComplete: false, hasSubmitted: false, choiceAnswers: new Map() });
}
/** 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;
state.hasSubmitted = true;
if (this.choiceQuestionIds.has(questionId)) {
state.choiceAnswers.set(questionId, { value, otherText });
}
}
markRespondentComplete(id: string): void {
const state = this._respondentState.get(id);
if (state) state.isComplete = true;
}
removeRespondent(id: string): void {
this._respondentState.delete(id);
}
get survey(): SurveyRow {
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);
this._survey = rows[0] as unknown as SurveyRow;
return this._survey;
}
get sections(): SectionRow[] {
if (this._sections) return this._sections;
this._sections = this.sql
.exec('SELECT * FROM survey_sections ORDER BY sort_order')
.toArray() as unknown as SectionRow[];
return this._sections;
}
get questions(): QuestionRow[] {
if (this._questions) return this._questions;
this._questions = this.sql
.exec('SELECT * FROM survey_questions ORDER BY sort_order')
.toArray() as unknown as QuestionRow[];
return this._questions;
}
get choiceQuestionIds(): Set<string> {
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;
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];
this._counters = { total: Number(row?.total ?? 0), completed: Number(row?.completed ?? 0) };
return this._counters;
}
get tallies(): Map<string, AnswerTally[]> {
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;
const placeholders = choiceIds.map(() => '?').join(',');
const rows = this.sql
.exec(
`SELECT a.question_id, a.answer, a.other_text, COUNT(*) as count
FROM answers a JOIN respondents r ON a.respondent_id = r.id
WHERE r.is_complete = 1 AND a.question_id IN (${placeholders})
GROUP BY a.question_id, a.answer, a.other_text`,
...choiceIds,
)
.toArray();
for (const row of rows) {
const qId = row.question_id as string;
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,
count: Number(row.count),
});
}
return this._tallies;
}
get hasSurvey(): boolean {
if (this._survey) return true;
const rows = this.sql.exec('SELECT id FROM surveys LIMIT 1').toArray();
return rows.length > 0;
}
invalidateSurvey(): void {
this._survey = null;
this._sections = null;
this._questions = null;
this._choiceQuestionIds = null;
}
invalidateResults(): void {
this._counters = null;
this._tallies = null;
this._respondentState.clear();
}
incrementTotal(): void {
if (this._counters) this._counters.total++;
}
incrementCompleted(): void {
if (this._counters) this._counters.completed++;
}
/**
* Folds the respondent's in-memory choice answers into the tallies. When that
* state is missing (sendBeacon-only respondent, reconnect after hibernation, op
* race) we drop _tallies so the next read rebuilds from SQL — otherwise
* incrementCompleted keeps bumping the total while per-option tallies stay frozen
* and the live charts silently drift.
*/
updateTalliesOnCompletion(respondentId: string): void {
if (!this._tallies) return;
const state = this._respondentState.get(respondentId);
if (!state) {
this._tallies = null;
return;
}
for (const [qId, ans] of state.choiceAnswers) {
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) {
existing.count++;
} else {
qTallies.push({
value: ans.value,
otherText: ans.otherText,
count: 1,
});
}
}
}
/**
* Choice questions only, straight from the in-memory tallies (no SQL) for the
* broadcast loop. Text-style questions are omitted — the frontend merges these
* with the results from its initial HTTP load.
*/
buildChoiceResults(): Array<{ questionId: string; answers: AnswerTally[] }> {
const tallies = this.tallies;
const results: Array<{ questionId: string; answers: AnswerTally[] }> = [];
for (const q of this.questions) {
if (CHOICE_TYPES.has(q.type)) {
results.push({ questionId: q.id, answers: tallies.get(q.id) ?? [] });
}
}
return results;
}
buildAggregatedResults(): Array<{ questionId: string; answers: AnswerTally[] }> {
const tallies = this.tallies;
const results: Array<{ questionId: string; answers: AnswerTally[] }> = [];
for (const q of this.questions) {
if (CHOICE_TYPES.has(q.type)) {
results.push({ questionId: q.id, answers: tallies.get(q.id) ?? [] });
} else {
const rows = this.sql
.exec(
`SELECT a.answer, a.other_text, COUNT(*) as count
FROM answers a JOIN respondents r ON a.respondent_id = r.id
WHERE r.is_complete = 1 AND a.question_id = ?
GROUP BY a.answer, a.other_text ORDER BY count DESC`,
q.id,
)
.toArray();
results.push({
questionId: q.id,
answers: rows.map((r) => ({
value: r.answer as string,
otherText: (r.other_text as string) || null,
count: Number(r.count),
})),
});
}
}
return results;
}
}
@@ -0,0 +1,126 @@
/**
* Commands shared by the DO's HTTP fallback path and its WebSocket handler.
*
* Returns `undefined` for operations with no response body, and `null` when the op
* is unrecognized — the declared `unknown | null` return type can't express either.
*/
import type { SurveyService } from '../services/survey.service';
import type { RespondentService } from '../services/respondent.service';
import type { SurveyCache } from './cache';
import { MAX_PAGINATION_LIMIT } from '../constants';
export interface CommandContext {
surveyService: SurveyService;
respondentService: RespondentService;
cache: SurveyCache;
}
export async function execute(op: string, data: Record<string, unknown>, ctx: CommandContext): Promise<unknown | null> {
const surveyId = ctx.cache.survey.id;
switch (op) {
case 'create-section': {
const result = await ctx.surveyService.createSection(surveyId, data as { title: string; description?: string });
ctx.cache.invalidateSurvey();
return result;
}
case 'update-section': {
const { id, ...input } = data as { id: string; title?: string; description?: string };
const result = await ctx.surveyService.updateSection(id, input);
ctx.cache.invalidateSurvey();
return result;
}
case 'delete-section': {
await ctx.surveyService.deleteSection(data.id as string);
ctx.cache.invalidateSurvey();
return undefined;
}
case 'reorder-sections': {
await ctx.surveyService.reorderSections(
surveyId,
(data as { items: Array<{ id: string; sort_order: number }> }).items,
);
ctx.cache.invalidateSurvey();
return undefined;
}
case 'create-question': {
const { sectionId, ...input } = data as { sectionId: string; [key: string]: unknown };
const result = await ctx.surveyService.createQuestion(sectionId, input as any);
ctx.cache.invalidateSurvey();
ctx.cache.invalidateResults();
return result;
}
case 'update-question': {
const { id, ...input } = data as { id: string; [key: string]: unknown };
const result = await ctx.surveyService.updateQuestion(id, input as any);
ctx.cache.invalidateSurvey();
return result;
}
case 'delete-question': {
await ctx.surveyService.deleteQuestion(data.id as string);
ctx.cache.invalidateSurvey();
ctx.cache.invalidateResults();
return undefined;
}
case 'reorder-questions': {
const { sectionId, items } = data as { sectionId: string; items: Array<{ id: string; sort_order: number }> };
await ctx.surveyService.reorderQuestions(sectionId, items);
ctx.cache.invalidateSurvey();
return undefined;
}
case 'get-results':
return ctx.respondentService.getResults(surveyId);
case 'get-timeline': {
const raw = data.granularity;
const granularity: 'minute' | 'hour' | 'day' = raw === 'minute' ? 'minute' : raw === 'hour' ? 'hour' : 'day';
return ctx.respondentService.getTimeline(surveyId, granularity);
}
case 'get-completion-times':
return ctx.respondentService.getCompletionTimes(surveyId);
case 'get-question-timings':
return ctx.respondentService.getQuestionTimings(surveyId);
case 'get-dropoff':
return ctx.respondentService.getDropoff(surveyId);
case 'list-respondents': {
const offset = Number(data.offset ?? 0);
const limit = Math.min(Number(data.limit ?? 20), MAX_PAGINATION_LIMIT);
return ctx.respondentService.listRespondents(surveyId, offset, limit);
}
case 'get-respondent':
return ctx.respondentService.getRespondentDetail(surveyId, data.respondentId as string);
case 'delete-respondent': {
await ctx.respondentService.deleteRespondent(surveyId, data.respondentId as string);
ctx.cache.invalidateResults();
return undefined;
}
case 'search-answers': {
const { query, questionId, offset, limit } = data as {
query: string;
questionId?: string;
offset?: number;
limit?: number;
};
return ctx.respondentService.searchAnswers(surveyId, query, questionId, {
offset: offset ?? 0,
limit: limit ?? 50,
});
}
case 'export-definition':
return ctx.surveyService.exportDefinition(surveyId);
default:
return null;
}
}
@@ -0,0 +1,84 @@
/**
* Kysely dialect over DO SQLite so the DO can reuse the same repositories and
* services as the D1-backed API worker. Modeled after CloudflareD1Dialect in
* @immich/kysely-adapter-cloudflare.
*/
import {
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
type DatabaseConnection,
type Dialect,
type Driver,
type Kysely,
type QueryResult,
type CompiledQuery,
} from 'kysely';
export interface CloudflareDODialectConfig {
storage: SqlStorage;
}
export class CloudflareDODialect implements Dialect {
constructor(private config: CloudflareDODialectConfig) {}
createAdapter() {
return new SqliteAdapter();
}
createDriver(): Driver {
return new CloudflareDODriver(this.config);
}
createQueryCompiler() {
return new SqliteQueryCompiler();
}
createIntrospector(db: Kysely<unknown>) {
return new SqliteIntrospector(db);
}
}
class CloudflareDODriver implements Driver {
private connection: CloudflareDOConnection;
constructor(private config: CloudflareDODialectConfig) {
this.connection = new CloudflareDOConnection(config.storage);
}
async init(): Promise<void> {}
async acquireConnection(): Promise<DatabaseConnection> {
return this.connection;
}
// DO SQLite doesn't support explicit transactions — all operations are auto-committed.
// These are no-ops to satisfy the Kysely driver interface.
async beginTransaction(): Promise<void> {}
async commitTransaction(): Promise<void> {}
async rollbackTransaction(): Promise<void> {}
async releaseConnection(): Promise<void> {}
async destroy(): Promise<void> {}
}
class CloudflareDOConnection implements DatabaseConnection {
constructor(private storage: SqlStorage) {}
executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
const { sql, parameters } = compiledQuery;
const cursor = this.storage.exec(sql, ...parameters);
const rows = cursor.toArray() as R[];
return Promise.resolve({
rows,
numAffectedRows: cursor.rowsWritten > 0 ? BigInt(cursor.rowsWritten) : undefined,
insertId: undefined,
});
}
streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
throw new Error('DO SQLite driver does not support streaming');
}
}
@@ -0,0 +1,13 @@
export { SurveyDO } from './survey-do';
// Legacy alias for the original KV-backed class (see wrangler-do.jsonc v1
// migration: `new_classes: ["SurveySession"]`). Cloudflare rejects a deploy
// that removes a class still declared in the migration chain, so the alias
// stays exported until a future `deleted_classes` migration retires v1.
export { SurveyDO as SurveySession } from './survey-do';
export default {
async fetch(): Promise<Response> {
return new Response('This worker only hosts Durable Objects', { status: 404 });
},
};
@@ -0,0 +1,106 @@
/** Table names must match the main Database interface so the shared Kysely queries work unchanged. */
const SCHEMA = `
CREATE TABLE IF NOT EXISTS surveys (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
slug TEXT,
status TEXT NOT NULL DEFAULT 'draft',
welcome_title TEXT,
welcome_description TEXT,
thank_you_title TEXT,
thank_you_description TEXT,
closes_at TEXT,
max_responses INTEGER,
randomize_questions INTEGER DEFAULT 0,
randomize_options INTEGER DEFAULT 0,
password_hash TEXT,
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS survey_sections (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_do_sections_survey ON survey_sections(survey_id);
CREATE TABLE IF NOT EXISTS survey_questions (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
section_id TEXT NOT NULL,
text TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL,
options TEXT,
required INTEGER NOT NULL DEFAULT 1,
has_other INTEGER NOT NULL DEFAULT 0,
other_prompt TEXT,
max_length INTEGER,
placeholder TEXT,
sort_order INTEGER NOT NULL,
conditional TEXT,
config TEXT
);
CREATE INDEX IF NOT EXISTS idx_do_questions_survey ON survey_questions(survey_id);
CREATE INDEX IF NOT EXISTS idx_do_questions_section ON survey_questions(section_id);
CREATE TABLE IF NOT EXISTS respondents (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
ip_address TEXT,
is_complete INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_do_respondents_survey ON respondents(survey_id);
CREATE TABLE IF NOT EXISTS answers (
respondent_id TEXT NOT NULL,
question_id TEXT NOT NULL,
answer TEXT NOT NULL,
other_text TEXT,
answered_at TEXT NOT NULL,
answer_ms INTEGER,
PRIMARY KEY (respondent_id, question_id)
);
`;
const initializedInstances = new WeakSet<SqlStorage>();
/** 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 {
const msg = e instanceof Error ? e.message.toLowerCase() : '';
return msg.includes('duplicate column') || msg.includes('already exists');
}
function applySchema(sql: SqlStorage): void {
sql.exec(SCHEMA);
for (const stmt of MIGRATIONS) {
try {
sql.exec(stmt);
} catch (e) {
if (!isAlreadyAppliedError(e)) throw e;
}
}
}
export function ensureSchema(sql: SqlStorage): void {
if (initializedInstances.has(sql)) return;
applySchema(sql);
initializedInstances.add(sql);
}
export function recreateSchemaAfterWipe(sql: SqlStorage): void {
applySchema(sql);
}
@@ -0,0 +1,595 @@
import { DurableObject } from 'cloudflare:workers';
import { Kysely } from 'kysely';
import { CloudflareDODialect } from './do-sqlite-dialect';
import { ensureSchema, recreateSchemaAfterWipe } from './schema';
import { SurveyCache } from './cache';
import { createSurveyService, createRespondentService } from '../services/factory';
import type { SurveyService, UpdateSurveyInput } from '../services/survey.service';
import type { RespondentService } from '../services/respondent.service';
import { ServiceError } from '../services/errors';
import { dispatch as wsDispatch } from './ws/ws-handler';
import {
broadcastToViewers,
broadcastSlowAnalytics,
scheduleBroadcast,
getPresenceCounts,
SLOW_TICKS_PER_CYCLE,
} from './ws/ws-broadcaster';
import { execute, type CommandContext } from './do-commands';
import { toClientSurvey } from '../utils/sanitize';
import { fingerprintMatchesPassword } from '../utils/survey-password-token';
import type { Database, SurveyRow, SectionRow, QuestionRow } from '../db';
import { SURVEY_ID_PATTERN, PUBLIC_PATTERN } from '../routing';
export class SurveyDO extends DurableObject {
private cache: SurveyCache;
private db: Kysely<Database>;
private surveyService: SurveyService;
private respondentService: RespondentService;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ensureSchema(ctx.storage.sql);
this.cache = new SurveyCache(ctx.storage.sql);
this.db = new Kysely<Database>({ dialect: new CloudflareDODialect({ storage: ctx.storage.sql }) });
this.surveyService = createSurveyService(this.db);
this.respondentService = createRespondentService(this.db);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.headers.get('Upgrade') === 'websocket') {
return this.handleWebSocketUpgrade(url, request);
}
try {
return await this.handleHttp(request, url);
} catch (e) {
if (e instanceof ServiceError) {
return Response.json({ error: e.message }, { status: e.status });
}
console.error('SurveyDO error:', e);
return Response.json({ error: 'Internal error' }, { status: 500 });
}
}
private handleWebSocketUpgrade(url: URL, request: Request): Response {
const type = url.searchParams.get('type');
if (type !== 'viewer' && type !== 'respondent' && type !== 'editor') {
return new Response('type must be viewer, respondent, or editor', { status: 400 });
}
// Use the verified role from the API worker (set after authentication)
const verifiedRole = request.headers.get('X-WS-Role') ?? 'public';
let respondentId: string | null = null;
let setCookieHeader: string | null = null;
if (type === 'respondent') {
const survey = this.cache.survey;
if (survey.status !== 'published') {
return new Response('Survey not found', { status: 404 });
}
if (survey.closes_at && new Date(survey.closes_at) < new Date()) {
return new Response('This survey has closed', { status: 403 });
}
if (survey.max_responses && this.cache.counters.completed >= survey.max_responses) {
return new Response('This survey has reached its maximum number of responses', { status: 403 });
}
// Password gate (authoritative — uses this DO's own cache which is
// properly invalidated on update; the worker only forwards a verified
// X-Authenticated header)
if (!this.passwordGateAllows(request)) {
return new Response('Authentication required', { status: 403 });
}
const existingId = request.headers.get('X-Respondent-Id');
if (existingId && this.cache.hasRespondent(existingId)) {
respondentId = existingId;
} else {
respondentId = crypto.randomUUID();
const ip = request.headers.get('CF-Connecting-IP') ?? request.headers.get('X-Forwarded-For') ?? 'unknown';
this.ctx.storage.sql.exec(
`INSERT INTO respondents (id, survey_id, ip_address, is_complete, created_at, completed_at)
VALUES (?, ?, ?, 0, ?, NULL)`,
respondentId,
survey.id,
ip,
new Date().toISOString(),
);
this.cache.initRespondent(respondentId);
this.cache.incrementTotal();
// Set cookie on the WS upgrade response — this is what enables the HTTP
// sendBeacon fallback on page unload to identify the respondent
if (survey.slug) {
const secure = request.url.startsWith('https://') ? 'Secure; ' : '';
setCookieHeader = `rid_${survey.slug}=${respondentId}; Path=/; HttpOnly; ${secure}SameSite=Lax; Max-Age=${60 * 60 * 24 * 90}`;
}
}
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
const tags: string[] = [type, `role:${verifiedRole}`];
if (respondentId) tags.push(`rid:${respondentId}`);
this.ctx.acceptWebSocket(server, tags);
const counts = getPresenceCounts(this.ctx);
server.send(JSON.stringify(counts));
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
const headers = new Headers();
if (setCookieHeader) headers.set('Set-Cookie', setCookieHeader);
return new Response(null, { status: 101, webSocket: client, headers });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
if (typeof message !== 'string') return;
const services = {
survey: this.surveyService,
respondent: this.respondentService,
};
await wsDispatch(ws, message, services, this.cache, this.ctx);
}
webSocketClose(): void {
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
}
webSocketError(ws: WebSocket): void {
ws.close(1011, 'WebSocket error');
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
}
async alarm(): Promise<void> {
this.cache.broadcastScheduled.value = false;
broadcastToViewers(this.ctx, this.cache);
this.cache.fastTick += 1;
if (this.cache.fastTick >= SLOW_TICKS_PER_CYCLE) {
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);
});
}
}
// Self-terminating loop: reschedule only while viewers are connected, so they
// keep getting periodic counts/stats even when nothing else is happening.
if (this.ctx.getWebSockets('viewer').length > 0) {
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
} else {
this.cache.fastTick = 0;
}
}
private async handleHttp(request: Request, url: URL): Promise<Response> {
const path = this.internalPath(url.pathname);
const method = request.method;
// Init (called by API worker on survey creation)
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 (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);
return this.handleSelfHostedFallback(method, path, request, url);
}
private async handleInit(request: Request): Promise<Response> {
const body = (await request.json()) as {
survey: SurveyRow;
sections?: SectionRow[];
questions?: QuestionRow[];
};
const s = body.survey;
// Wipe any prior state before re-initializing so that re-init (e.g. via
// restore or duplicate-into-existing-id) doesn't leave stale rows from a
// previous survey that lived in this DO.
this.ctx.storage.sql.exec('DELETE FROM answers');
this.ctx.storage.sql.exec('DELETE FROM respondents');
this.ctx.storage.sql.exec('DELETE FROM survey_questions');
this.ctx.storage.sql.exec('DELETE FROM survey_sections');
this.ctx.storage.sql.exec('DELETE FROM surveys');
this.ctx.storage.sql.exec(
`INSERT OR REPLACE INTO surveys (id, title, description, slug, status, welcome_title,
welcome_description, thank_you_title, thank_you_description, closes_at, max_responses,
randomize_questions, randomize_options, password_hash, archived_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.id,
s.title,
s.description,
s.slug,
s.status,
s.welcome_title,
s.welcome_description,
s.thank_you_title,
s.thank_you_description,
s.closes_at,
s.max_responses,
s.randomize_questions,
s.randomize_options,
s.password_hash,
s.archived_at,
s.created_at,
s.updated_at,
);
for (const sec of body.sections ?? []) {
this.ctx.storage.sql.exec(
`INSERT OR REPLACE INTO survey_sections (id, survey_id, title, description, sort_order) VALUES (?, ?, ?, ?, ?)`,
sec.id,
sec.survey_id,
sec.title,
sec.description,
sec.sort_order,
);
}
for (const q of body.questions ?? []) {
this.ctx.storage.sql.exec(
`INSERT OR REPLACE INTO survey_questions (id, survey_id, section_id, text, description, type, options,
required, has_other, other_prompt, max_length, placeholder, sort_order, conditional, config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
q.id,
q.survey_id,
q.section_id,
q.text,
q.description,
q.type,
q.options,
q.required,
q.has_other,
q.other_prompt,
q.max_length,
q.placeholder,
q.sort_order,
q.conditional,
q.config,
);
}
this.cache.invalidateSurvey();
return new Response(null, { status: 204 });
}
private handleGetSurvey(): Response {
return Response.json({
survey: toClientSurvey(this.cache.survey),
sections: this.cache.sections,
questions: this.cache.questions,
});
}
private async handleUpdateSurvey(request: Request): Promise<Response> {
const input = (await request.json()) as UpdateSurveyInput;
const result = await this.surveyService.updateSurvey(this.cache.survey.id, input, this.cache.survey);
this.cache.invalidateSurvey();
return Response.json(toClientSurvey(result), { headers: this.catalogSyncHeaders() });
}
private async handleDeleteSurvey(): Promise<Response> {
for (const ws of this.ctx.getWebSockets()) {
try {
ws.close(1000, 'Survey deleted');
} catch {
/* ignore */
}
}
// deleteAll() drops the SQLite tables too, and this same live instance serves
// the next request — without recreating the empty schema, queries would throw
// raw SQL errors (500) instead of the intended 404 from `cache.survey`.
await this.ctx.storage.deleteAll();
recreateSchemaAfterWipe(this.ctx.storage.sql);
this.cache.invalidateSurvey();
this.cache.invalidateResults();
return new Response(null, { status: 204 });
}
private async handlePublish(): Promise<Response> {
const details = { survey: this.cache.survey, sections: this.cache.sections, questions: this.cache.questions };
const result = await this.surveyService.publishSurvey(this.cache.survey.id, details);
this.cache.invalidateSurvey();
return Response.json(toClientSurvey(result), { headers: this.catalogSyncHeaders() });
}
private async handleUnpublish(): Promise<Response> {
const result = await this.surveyService.unpublishSurvey(this.cache.survey.id, this.cache.survey);
this.cache.invalidateSurvey();
return Response.json(toClientSurvey(result), { headers: this.catalogSyncHeaders() });
}
private async handleArchive(): Promise<Response> {
const result = await this.surveyService.archiveSurvey(this.cache.survey.id, this.cache.survey);
this.cache.invalidateSurvey();
return Response.json(toClientSurvey(result), { headers: this.catalogSyncHeaders() });
}
private async handleUnarchive(): Promise<Response> {
const result = await this.surveyService.unarchiveSurvey(this.cache.survey.id, this.cache.survey);
this.cache.invalidateSurvey();
return Response.json(toClientSurvey(result), { headers: this.catalogSyncHeaders() });
}
private handleDuplicate(): Response {
const survey = this.cache.survey;
const sections = this.cache.sections;
const questions = this.cache.questions;
const now = new Date().toISOString();
const newSurvey: SurveyRow = {
...survey,
id: crypto.randomUUID(),
title: `${survey.title} (Copy)`,
slug: null,
status: 'draft',
password_hash: null,
archived_at: null,
created_at: now,
updated_at: now,
};
const sectionIdMap = new Map<string, string>();
const newSections = sections.map((s) => {
const newId = crypto.randomUUID();
sectionIdMap.set(s.id, newId);
return { ...s, id: newId, survey_id: newSurvey.id };
});
const newQuestions = questions.map((q) => ({
...q,
id: crypto.randomUUID(),
survey_id: newSurvey.id,
section_id: sectionIdMap.get(q.section_id) ?? q.section_id,
}));
return Response.json({ survey: newSurvey, sections: newSections, questions: newQuestions }, { status: 201 });
}
private async handleExportResults(url: URL): Promise<Response> {
const format = url.searchParams.get('format');
if (format !== 'csv' && format !== 'json') {
return Response.json({ error: 'format must be csv or json' }, { status: 400 });
}
const result = await this.respondentService.exportResponses(this.cache.survey.id, format);
return new Response(result.data, {
headers: {
'Content-Type': result.contentType,
'Content-Disposition': `attachment; filename="${result.filename}"`,
},
});
}
private handleGetPublicSurvey(request: Request): Response {
const survey = this.cache.survey;
if (survey.status !== 'published') {
return Response.json({ error: 'Survey not found' }, { status: 404 });
}
const isAuthenticated = this.passwordGateAllows(request);
if (!isAuthenticated) {
return Response.json({
survey: {
id: survey.id,
title: survey.title,
description: survey.description,
slug: survey.slug,
status: survey.status,
welcome_title: survey.welcome_title,
welcome_description: survey.welcome_description,
},
sections: [],
questions: [],
requiresPassword: true,
});
}
const { password_hash: _, ...safeSurvey } = survey;
return Response.json({ survey: safeSurvey, sections: this.cache.sections, questions: this.cache.questions });
}
private async handleResume(request: Request): Promise<Response> {
const survey = this.cache.survey;
if (survey.status !== 'published') {
return Response.json({ error: 'Survey not found' }, { status: 404 });
}
if (!this.passwordGateAllows(request)) {
return Response.json({ error: 'Authentication required' }, { status: 403 });
}
const respondentId = request.headers.get('X-Respondent-Id') || undefined;
const ip = request.headers.get('CF-Connecting-IP') ?? request.headers.get('X-Forwarded-For') ?? 'unknown';
const result = await this.respondentService.resume(survey.slug!, respondentId, ip, survey);
if (result.isNewRespondent) {
this.cache.incrementTotal();
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
}
const response = Response.json({
answers: result.answers,
nextQuestionIndex: result.nextQuestionIndex,
isComplete: result.isComplete,
});
if (result.isNewRespondent) {
response.headers.set('X-Respondent-Id', result.respondentId);
response.headers.set('X-New-Respondent', 'true');
}
return response;
}
private async handleSubmitAnswers(request: Request): Promise<Response> {
const survey = this.cache.survey;
if (!this.passwordGateAllows(request)) {
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 });
const { answers } = (await request.json()) as {
answers: Array<{ questionId: string; value: string; otherText?: string; answerMs?: number }>;
};
await this.respondentService.submitBatch(survey.slug!, respondentId, answers, survey);
return new Response(null, { status: 204 });
}
private async handleComplete(request: Request): Promise<Response> {
const survey = this.cache.survey;
if (!this.passwordGateAllows(request)) {
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 });
const completed = await this.respondentService.complete(survey.slug!, respondentId, survey);
if (completed) {
this.cache.incrementCompleted();
this.cache.updateTalliesOnCompletion(respondentId);
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
}
return new Response(null, { status: 204 });
}
private async handleSelfHostedFallback(method: string, path: string, request: Request, url: URL): Promise<Response> {
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);
return new Response('Not found', { status: 404 });
}
const body = ['POST', 'PUT'].includes(method) ? ((await request.json()) as Record<string, unknown>) : {};
const data = { ...route.params, ...body };
const result = await execute(route.op, data, this.commandContext());
if (result === undefined) return new Response(null, { status: 204 });
return Response.json(result, { status: route.status ?? 200 });
}
private matchFallbackRoute(
method: string,
path: string,
url: URL,
): { op: string; params: Record<string, unknown>; status?: number } | null {
// Sections — reorder before /:id pattern
if (method === 'POST' && path === '/sections') return { op: 'create-section', params: {}, status: 201 };
if (method === 'PUT' && path === '/sections/reorder') return { op: 'reorder-sections', params: {} };
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] } };
match = path.match(/^\/sections\/([^/]+)\/questions\/reorder$/);
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 };
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 (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: {} };
if (method === 'GET' && path === '/results/respondents')
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 (method === 'GET' && path === '/results/search')
return {
op: 'search-answers',
params: {
query: url.searchParams.get('q') ?? '',
questionId: url.searchParams.get('questionId') ?? undefined,
offset: url.searchParams.get('offset'),
limit: url.searchParams.get('limit'),
},
};
if (method === 'GET' && path === '/definition') return { op: 'export-definition', params: {} };
return null;
}
private async handleLiveResults(request: Request): Promise<Response> {
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 });
const response = Response.json(results);
response.headers.set('ETag', etag);
response.headers.set('Cache-Control', 'private, no-cache');
return response;
}
private commandContext(): CommandContext {
return {
surveyService: this.surveyService,
respondentService: this.respondentService,
cache: this.cache,
};
}
private internalPath(pathname: string): string {
const surveyMatch = pathname.match(SURVEY_ID_PATTERN);
if (surveyMatch) return surveyMatch[2] || '/';
const publicMatch = pathname.match(PUBLIC_PATTERN);
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;
return fingerprintMatchesPassword(request.headers.get('X-Survey-Pw-Fp'), survey.password_hash);
}
private catalogSyncHeaders(): HeadersInit {
const survey = this.cache.survey;
return {
'X-Catalog-Sync': JSON.stringify({
title: survey.title,
slug: survey.slug,
status: survey.status,
description: survey.description,
password_hash: survey.password_hash,
archived_at: survey.archived_at,
updated_at: survey.updated_at,
welcome_title: survey.welcome_title,
welcome_description: survey.welcome_description,
thank_you_title: survey.thank_you_title,
thank_you_description: survey.thank_you_description,
closes_at: survey.closes_at,
max_responses: survey.max_responses,
randomize_questions: survey.randomize_questions,
randomize_options: survey.randomize_options,
}),
};
}
}
@@ -0,0 +1,106 @@
/**
* Two-tier broadcast model for the SurveyDO:
*
* FAST (5s) — presence, counters and choice results; pure in-memory, no SQL.
* SLOW (60s) — the SQL aggregations that can't be maintained incrementally
* (drop-off, timeline, completion times). Computed once per cycle
* and fanned out, so N viewers never become N query sets.
*
* Both tiers are skipped entirely when no viewer is connected.
*/
import type { RespondentService } from '../../services/respondent.service';
import type { SurveyCache } from '../cache';
import { BROADCAST_FAST_INTERVAL_MS, BROADCAST_SLOW_TICKS_PER_CYCLE } from '../../constants';
const BROADCAST_INTERVAL_MS = BROADCAST_FAST_INTERVAL_MS;
const SLOW_TICKS_PER_CYCLE = BROADCAST_SLOW_TICKS_PER_CYCLE;
export function getPresenceCounts(ctx: DurableObjectState): {
type: 'push';
event: 'counts';
data: { activeViewers: number; activeRespondents: number };
} {
return {
type: 'push',
event: 'counts',
data: {
activeViewers: ctx.getWebSockets('viewer').length,
activeRespondents: ctx.getWebSockets('respondent').length,
},
};
}
export function broadcastToViewers(ctx: DurableObjectState, cache: SurveyCache): void {
const viewers = ctx.getWebSockets('viewer');
if (viewers.length === 0) return;
const counts = getPresenceCounts(ctx);
const counters = cache.counters;
const stats = {
type: 'push' as const,
event: 'stats' as const,
data: {
total: counters.total,
completed: counters.completed,
completionRate: counters.total > 0 ? Math.round((counters.completed / counters.total) * 100) : 0,
},
};
const results = {
type: 'push' as const,
event: 'results' as const,
data: {
respondentCounts: counters,
results: cache.buildChoiceResults(),
},
};
for (const ws of viewers) {
try {
ws.send(JSON.stringify(counts));
ws.send(JSON.stringify(stats));
ws.send(JSON.stringify(results));
} catch {
ws.close(1011, 'send failed');
}
}
}
export function scheduleBroadcast(ctx: DurableObjectState, scheduled: { value: boolean }): void {
if (scheduled.value) return;
scheduled.value = true;
ctx.storage.setAlarm(Date.now() + BROADCAST_INTERVAL_MS);
}
export async function broadcastSlowAnalytics(
ctx: DurableObjectState,
surveyId: string,
respondents: RespondentService,
): Promise<void> {
const viewers = ctx.getWebSockets('viewer');
if (viewers.length === 0) return;
const [timeline, dropoff, completionTimes, questionTimings] = await Promise.all([
respondents.getTimeline(surveyId, 'minute'),
respondents.getDropoff(surveyId),
respondents.getCompletionTimes(surveyId),
respondents.getQuestionTimings(surveyId),
]);
const payload = {
type: 'push' as const,
event: 'analytics' as const,
data: { timeline, dropoff, completionTimes, questionTimings },
};
const serialized = JSON.stringify(payload);
for (const ws of viewers) {
try {
ws.send(serialized);
} catch {
ws.close(1011, 'send failed');
}
}
}
export { SLOW_TICKS_PER_CYCLE };
@@ -0,0 +1,314 @@
import type { WsOperations } from '../../../../shared/ws-protocol';
import type { SurveyService } from '../../services/survey.service';
import type { RespondentService } from '../../services/respondent.service';
import type { SurveyCache } from '../cache';
import { ServiceError } from '../../services/errors';
import { toClientSurvey } from '../../utils/sanitize';
import { ROLE_HIERARCHY, BATCH_ANSWER_LIMIT, clampAnswerMs } from '../../constants';
import { validateAnswer, type QuestionSpec } from '../../../../shared/answer-validation';
import { execute, type CommandContext } from '../do-commands';
import { getPresenceCounts, scheduleBroadcast } from './ws-broadcaster';
interface Services {
survey: SurveyService;
respondent: RespondentService;
}
function send(ws: WebSocket, data: unknown): void {
try {
ws.send(JSON.stringify(data));
} catch {
ws.close(1011, 'send failed');
}
}
function respond(ws: WebSocket, requestId: string, op: string, data: unknown): void {
send(ws, { type: 'response', requestId, op, data });
}
function respondError(ws: WebSocket, requestId: string, op: string, message: string): void {
send(ws, { type: 'response', requestId, op, error: message });
}
/**
* Declarative authorization table. Every op handled by the dispatcher MUST
* appear here — there is no implicit default. Unmapped ops are rejected with
* "Unknown operation" so a future op added to the switch below (or to
* do-commands.execute) can't silently be exposed without an auth decision.
*
* 'public' marks respondent survey-taking ops that don't require any role.
*/
type MinRole = 'public' | 'viewer' | 'editor' | 'admin';
const OP_ROLES: Record<string, MinRole> = {
'get-public-survey': 'public',
resume: 'public',
'submit-answers': 'public',
complete: 'public',
'delete-respondent': 'admin',
'export-definition': 'editor',
'create-section': 'editor',
'update-section': 'editor',
'delete-section': 'editor',
'reorder-sections': 'editor',
'create-question': 'editor',
'update-question': 'editor',
'delete-question': 'editor',
'reorder-questions': 'editor',
'get-survey': 'viewer',
'get-results': 'viewer',
'get-live-results': 'viewer',
'get-timeline': 'viewer',
'get-dropoff': 'viewer',
'get-completion-times': 'viewer',
'get-question-timings': 'viewer',
'list-respondents': 'viewer',
'get-respondent': 'viewer',
'search-answers': 'viewer',
};
function getWsRole(ws: WebSocket, ctx: DurableObjectState): string {
for (const tag of ctx.getTags(ws)) {
if (tag.startsWith('role:')) return tag.slice(5);
}
return 'public';
}
function hasMinRole(ws: WebSocket, ctx: DurableObjectState, minRole: string): boolean {
return (ROLE_HIERARCHY[getWsRole(ws, ctx)] ?? 0) >= (ROLE_HIERARCHY[minRole] ?? 0);
}
function getWsRespondentId(ws: WebSocket, ctx: DurableObjectState): string | null {
for (const tag of ctx.getTags(ws)) {
if (tag.startsWith('rid:')) return tag.slice(4);
}
return null;
}
export async function dispatch(
ws: WebSocket,
message: string,
services: Services,
cache: SurveyCache,
ctx: DurableObjectState,
): Promise<void> {
let parsed: { type?: string; requestId?: string; op?: string; data?: Record<string, unknown> };
try {
parsed = JSON.parse(message);
} catch {
send(ws, { type: 'response', requestId: '', op: '', error: 'Invalid JSON' });
return;
}
if (parsed.type !== 'request' || !parsed.requestId || !parsed.op) {
send(ws, {
type: 'response',
requestId: parsed.requestId ?? '',
op: parsed.op ?? '',
error: 'Invalid message format',
});
return;
}
const { requestId, op, data } = parsed;
const d = data ?? {};
const requiredRole = OP_ROLES[op];
if (!requiredRole) {
respondError(ws, requestId, op, `Unknown operation: ${op}`);
return;
}
if (requiredRole !== 'public' && !hasMinRole(ws, ctx, requiredRole)) {
respondError(ws, requestId, op, 'Insufficient permissions');
return;
}
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';
respondError(ws, requestId, op, message);
}
}
async function handleOp(
ws: WebSocket,
op: keyof WsOperations,
data: Record<string, unknown>,
{ survey: surveyService, respondent: respondentService }: Services,
cache: SurveyCache,
ctx: DurableObjectState,
): Promise<unknown> {
const cmdCtx: CommandContext = { surveyService, respondentService, cache };
switch (op) {
case 'get-survey': {
const detail = await surveyService.getSurvey(cache.survey.id);
return { ...detail, survey: toClientSurvey(detail.survey) };
}
case 'get-results':
return {
respondentCounts: cache.counters,
results: cache.buildAggregatedResults(),
};
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);
const { password_hash: _, ...safeSurvey } = survey;
return { survey: safeSurvey, sections: cache.sections, questions: cache.questions };
}
case 'resume': {
// 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);
const cachedState = cache.getCachedRespondent(respondentId);
if (cachedState && !cachedState.hasSubmitted) {
return { answers: {}, nextQuestionIndex: 0, isComplete: cachedState.isComplete, respondentId };
}
const answerRows = ctx.storage.sql
.exec('SELECT question_id, answer, other_text FROM answers WHERE respondent_id = ?', respondentId)
.toArray() as Array<{ question_id: string; answer: string; other_text: string | null }>;
const answers: Record<string, { value: string; otherText?: string }> = {};
for (const row of answerRows) {
answers[row.question_id] = {
value: row.answer,
...(row.other_text ? { otherText: row.other_text } : {}),
};
}
let isComplete = cachedState?.isComplete ?? false;
if (!cachedState) {
const row = ctx.storage.sql
.exec('SELECT is_complete FROM respondents WHERE id = ? LIMIT 1', respondentId)
.toArray()[0] as { is_complete: number } | undefined;
isComplete = row?.is_complete === 1;
}
// Resume ON the last answered question (not the first unanswered one, which
// would send the user backwards past optional questions they deliberately
// skipped) so they can review and complete it.
const questions = cache.questions;
let lastAnsweredIndex = -1;
for (let i = 0; i < questions.length; i++) {
if (questions[i].id in answers) lastAnsweredIndex = i;
}
const nextQuestionIndex = Math.max(0, lastAnsweredIndex);
return { answers, nextQuestionIndex, isComplete, respondentId };
}
case 'submit-answers': {
const respondentId = getWsRespondentId(ws, ctx);
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()) {
throw new ServiceError('This survey has closed', 403);
}
// Completion is terminal (see RespondentService.submitBatch). When the cached
// state has been evicted we must still hit SQL, or a completed respondent
// could keep submitting answers.
const cachedState = cache.getCachedRespondent(respondentId);
let isComplete = cachedState?.isComplete ?? false;
if (!cachedState) {
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);
isComplete = row.is_complete === 1;
}
if (isComplete) throw new ServiceError('Survey already completed', 409);
const answers = (
data as {
answers?: Array<{ questionId: string; value: string; otherText?: string; answerMs?: number }>;
}
).answers;
if (!answers || !Array.isArray(answers) || answers.length === 0 || answers.length > BATCH_ANSWER_LIMIT) {
throw new ServiceError(`Invalid answers payload: must be 1-${BATCH_ANSWER_LIMIT} answers`, 400);
}
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);
const spec: QuestionSpec = {
type: cq.type,
required: cq.required === 1,
options: cq.options ? JSON.parse(cq.options) : undefined,
hasOther: cq.has_other === 1,
maxLength: cq.max_length ?? undefined,
config: cq.config ? JSON.parse(cq.config) : undefined,
};
const error = validateAnswer(spec, a.value, a.otherText);
if (error) throw new ServiceError(error, 400);
}
for (const a of answers) {
cache.setAnswer(respondentId, a.questionId, a.value, a.otherText ?? null);
}
// answer_ms is clamped via the shared helper so it stays in lockstep with the
// HTTP path in respondent.service.
const now = new Date().toISOString();
const placeholders = answers.map(() => '(?, ?, ?, ?, ?, ?)').join(', ');
const values: unknown[] = [];
for (const a of answers) {
values.push(respondentId, a.questionId, a.value, a.otherText ?? null, now, clampAnswerMs(a.answerMs));
}
ctx.storage.sql.exec(
`INSERT OR REPLACE INTO answers (respondent_id, question_id, answer, other_text, answered_at, answer_ms) VALUES ${placeholders}`,
...values,
);
return {};
}
case 'complete': {
// Idempotent: a duplicate `complete` (flaky retry, malicious replay) must not
// 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);
const cursor = ctx.storage.sql.exec(
'UPDATE respondents SET is_complete = 1, completed_at = ? WHERE id = ? AND is_complete = 0',
new Date().toISOString(),
respondentId,
);
const transitioned = cursor.rowsWritten > 0;
if (transitioned) {
cache.markRespondentComplete(respondentId);
cache.updateTalliesOnCompletion(respondentId);
cache.incrementCompleted();
cache.removeRespondent(respondentId);
scheduleBroadcast(ctx, cache.broadcastScheduled);
}
return {};
}
default: {
const opStr = String(op);
const result = await execute(opStr, data, cmdCtx);
if (result === null) throw new ServiceError(`Unknown operation: ${opStr}`, 400);
return result ?? {};
}
}
}
+550
View File
@@ -0,0 +1,550 @@
import { AutoRouter, cors, IRequest } from 'itty-router';
import { ServiceError } from './services/errors';
import { registerAuthRoutes } from './routes/auth';
import { registerSurveyRoutes } from './routes/surveys';
import { registerRespondentRoutes } from './routes/respondents';
import { registerResultRoutes } from './routes/results';
import { registerTagRoutes } from './routes/tags';
import { registerAuditRoutes } from './routes/audit';
import { registerBackupRoutes } from './routes/backup';
import { authMiddleware } from './middleware/auth';
import { configFromEnv, type AppContext } from './config';
import { createD1Database } from './db';
import { getCookie, getRespondentId, setRespondentCookie } from './cookie';
import { verifySurveyPasswordTokenSignature } from './utils/survey-password-token';
import { verifySessionToken } from './utils/session';
import { ROLE_HIERARCHY, type UserRole } from './constants';
import { SURVEY_ID_PATTERN, PUBLIC_PATTERN } from './routing';
const { preflight, corsify } = cors();
function securityHeaders(response: Response): Response {
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return response;
}
function registerAllRoutes(router: ReturnType<typeof AutoRouter>) {
registerAuthRoutes(router as any);
registerSurveyRoutes(router as any);
registerRespondentRoutes(router as any);
registerResultRoutes(router as any);
registerTagRoutes(router as any);
registerAuditRoutes(router as any);
registerBackupRoutes(router as any);
}
// In Node.js, Response.json() returns an undici Response that fails `instanceof Response`
// 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' } });
};
export function createRouter(ctx: AppContext) {
const nodeRouter = AutoRouter<IRequest & { ctx?: AppContext }>({
format: nodeFormat,
before: [
preflight,
(request: IRequest & { ctx?: AppContext }) => {
request.ctx = ctx;
},
(request: IRequest & { ctx?: AppContext }) => authMiddleware(request.ctx!)(request),
],
finally: [securityHeaders, corsify],
catch: (error) => {
if (error instanceof ServiceError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error('Unhandled error:', error);
return Response.json({ error: 'Internal server error' }, { status: 500 });
},
});
registerAllRoutes(nodeRouter);
return nodeRouter;
}
const router = AutoRouter<IRequest & { ctx?: AppContext }, [Env, ExecutionContext]>({
before: [
preflight,
(request: IRequest & { ctx?: AppContext }, env: Env) => {
const config = configFromEnv(env);
const db = createD1Database(env.DB);
request.ctx = { db, config };
},
(request: IRequest & { ctx?: AppContext }) => authMiddleware(request.ctx!)(request),
],
finally: [securityHeaders, corsify],
catch: (error) => {
if (error instanceof ServiceError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error('Unhandled error:', error);
return Response.json({ error: 'Internal server error' }, { status: 500 });
},
});
registerAllRoutes(router);
/**
* Per-isolate cache for slug → survey ID lookups. Without it every public
* request and WS upgrade on /api/s/:slug/* hits D1, whose read throughput
* (~2000/s) then bottlenecks WS connects. Module scope means the map is shared
* across all requests in an isolate; the TTL bounds cross-isolate staleness.
*
* password_hash is deliberately NOT cached: a stale `null` after an admin adds
* a password would let unauthenticated respondents through for the TTL window
* in other isolates. The DO gates on its own properly-invalidated copy instead.
*/
interface CachedSlugEntry {
id: string;
cachedAt: number;
}
const SLUG_CACHE_TTL_MS = 60_000;
const MAX_SLUG_CACHE_ENTRIES = 1000;
const slugCache = new Map<string, CachedSlugEntry>();
/**
* Internal headers that the API worker uses to tell the DO the verified
* identity of a request. These MUST never be sourced from the client — a
* client sending any of these is trying to spoof auth. We strip them from
* every inbound request before forwarding, then the worker explicitly sets
* the ones it has verified.
*/
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);
}
async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } | null> {
const cached = slugCache.get(slug);
if (cached && Date.now() - cached.cachedAt < SLUG_CACHE_TTL_MS) {
return { id: cached.id };
}
const row = await db.prepare('SELECT id FROM surveys WHERE slug = ?').bind(slug).first<{ id: string }>();
if (row) {
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);
}
}
return row;
}
/**
* Call after any change to the slug→surveyId mapping (survey delete, slug
* change) so this isolate stops serving stale routes; other isolates rely on
* the TTL to expire.
*/
function invalidateSlugCacheBySurveyId(surveyId: string): void {
for (const [slug, entry] of slugCache.entries()) {
if (entry.id === surveyId) slugCache.delete(slug);
}
}
function getDOStub(env: Env, surveyId: string): DurableObjectStub {
const id = env.SURVEY_SESSIONS.idFromName(surveyId);
return env.SURVEY_SESSIONS.get(id);
}
const CATALOG_SYNC_COLUMNS = new Set([
'title',
'slug',
'status',
'description',
'password_hash',
'archived_at',
'updated_at',
'welcome_title',
'welcome_description',
'thank_you_title',
'thank_you_description',
'closes_at',
'max_responses',
'randomize_questions',
'randomize_options',
]);
async function syncCatalog(response: Response, db: D1Database, surveyId: string): Promise<void> {
const syncHeader = response.headers.get('X-Catalog-Sync');
if (!syncHeader) return;
try {
const fields = JSON.parse(syncHeader) as Record<string, unknown>;
const keys = Object.keys(fields).filter((k) => CATALOG_SYNC_COLUMNS.has(k));
if (keys.length === 0) return;
const setClauses = keys.map((k) => `${k} = ?`).join(', ');
const values = [...keys.map((k) => fields[k]), surveyId];
await db
.prepare(`UPDATE surveys SET ${setClauses} WHERE id = ?`)
.bind(...values)
.run();
// password_hash changes need no invalidation — the cache no longer stores
// it; the DO enforces the password gate authoritatively.
if ('slug' in fields) {
invalidateSlugCacheBySurveyId(surveyId);
}
} catch {
console.error('Failed to sync catalog for survey', surveyId);
}
}
async function initDO(env: Env, surveyId: string, data: unknown): Promise<void> {
const stub = getDOStub(env, surveyId);
const res = await stub.fetch(
new Request('https://do/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`DO init failed (${res.status}): ${text}`);
}
}
function cleanResponse(response: Response): Response {
const cleaned = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers),
});
cleaned.headers.delete('X-Catalog-Sync');
cleaned.headers.delete('X-Respondent-Id');
cleaned.headers.delete('X-New-Respondent');
return cleaned;
}
function matchDORoute(method: string, pathname: string): { surveyId?: string; slug?: string } | null {
const surveyMatch = pathname.match(SURVEY_ID_PATTERN);
if (surveyMatch) {
const id = surveyMatch[1];
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;
return { surveyId: id };
}
const publicMatch = pathname.match(PUBLIC_PATTERN);
if (publicMatch) {
const slug = publicMatch[1];
const subPath = publicMatch[2] || '';
if (subPath === '/auth' || subPath === '/reset') return null;
return { slug };
}
return null;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!env.SURVEY_SESSIONS) {
return router.fetch(request, env, ctx);
}
const url = new URL(request.url);
const method = request.method;
const pathname = url.pathname;
if (method === 'OPTIONS') {
return router.fetch(request, env, ctx);
}
const doMatch = matchDORoute(method, pathname);
if (!doMatch) {
const response = await router.fetch(request, env, ctx);
if (response.ok && method === 'POST' && (pathname === '/api/surveys' || pathname === '/api/surveys/import')) {
const body = await response.json();
const surveyData = body.survey ?? body;
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);
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 });
}
}
// Response.json recomputes content-type/length for the re-serialized
// body; a stale content-length here would be rejected downstream.
const newHeaders = new Headers(response.headers);
newHeaders.delete('content-length');
newHeaders.delete('content-type');
return Response.json(body, { status: response.status, headers: newHeaders });
}
return response;
}
const config = configFromEnv(env);
let surveyId: string;
if (doMatch.surveyId) {
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 });
surveyId = row.id;
} else {
return router.fetch(request, env, ctx);
}
const isPublicRoute = !!doMatch.slug;
// Authenticate: admin routes need auth + role check.
// Public routes: the worker doesn't gate on password requirements (which
// would require knowing the current password_hash and risk cross-isolate
// staleness). Instead, the worker verifies the spw_ cookie if present and
// forwards an X-Authenticated header — the DO checks its own
// (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;
const subPath = pathname.match(SURVEY_ID_PATTERN)?.[2] || '/';
const requiredRole = getRequiredDORole(method, subPath);
if (!checkRole(authResult, requiredRole)) {
return Response.json({ error: 'Insufficient permissions' }, { status: 403 });
}
}
const stub = getDOStub(env, surveyId);
if (request.headers.get('Upgrade') === 'websocket') {
const wsUrl = new URL(request.url);
const wsType = wsUrl.searchParams.get('type');
// Viewer and editor WS connections are admin features — authenticate via session cookie
// and pass the real role to the DO so it can allow/deny per-op based on role hierarchy.
let wsRole = 'public';
if (wsType === 'viewer' || wsType === 'editor') {
const authResult = await authenticateRequest(request, config);
if (authResult instanceof Response) return authResult;
const minRole = wsType === 'editor' ? 'editor' : 'viewer';
if (!checkRole(authResult, minRole)) {
return Response.json({ error: 'Insufficient permissions' }, { status: 403 });
}
wsRole = authResult;
}
const wsHeaders = new Headers(request.headers);
stripInternalHeaders(wsHeaders);
wsHeaders.set('X-WS-Role', wsRole);
if (isPublicRoute && doMatch.slug) {
// 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);
// Verified unconditionally — the DO decides whether a password is
// actually required, from its own current password_hash.
const token = getCookie(request, `spw_${doMatch.slug}`);
const pw = token
? 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);
}
return stub.fetch(new Request(request.url, { method: request.method, headers: wsHeaders }));
}
// Slug uniqueness must be checked here: each survey lives in its own DO and
// can't see siblings, and the D1 catalog sync that would trip UNIQUE(slug)
// runs in waitUntil, where its failure is invisible to the client. The body
// is buffered so it can still be forwarded after the check.
let forwardBody: BodyInit | null = request.body;
const surveyPutSubPath = pathname.match(SURVEY_ID_PATTERN)?.[2] ?? '';
if (method === 'PUT' && doMatch.surveyId && (surveyPutSubPath === '' || surveyPutSubPath === '/')) {
const raw = await request.text();
forwardBody = raw;
try {
const parsed = JSON.parse(raw) as { slug?: unknown };
if (typeof parsed.slug === 'string' && parsed.slug.trim() !== '') {
const conflict = await env.DB.prepare('SELECT id FROM surveys WHERE slug = ? AND id != ?')
.bind(parsed.slug, surveyId)
.first<{ id: string }>();
if (conflict) {
return Response.json({ error: 'Slug is already in use' }, { status: 409 });
}
}
} catch {
// Non-JSON / unparseable body — let the DO's own validation reject it.
}
}
const doHeaders = new Headers(request.headers);
stripInternalHeaders(doHeaders);
if (isPublicRoute) {
// 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);
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);
}
const doRequest = new Request(request.url, {
method: request.method,
headers: doHeaders,
body: forwardBody,
});
const doResponse = await stub.fetch(doRequest);
const isDuplicate = method === 'POST' && pathname.endsWith('/duplicate') && doResponse.status === 201;
if (isDuplicate) {
const dupData = (await doResponse.json()) as {
survey: Record<string, unknown>;
sections: unknown[];
questions: unknown[];
};
const newId = dupData.survey.id as string;
const s = dupData.survey;
await env.DB.prepare(
`INSERT INTO surveys (id, title, description, slug, status, welcome_title, welcome_description,
thank_you_title, thank_you_description, closes_at, max_responses, randomize_questions,
randomize_options, password_hash, archived_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
s.id,
s.title,
s.description,
s.slug,
s.status,
s.welcome_title,
s.welcome_description,
s.thank_you_title,
s.thank_you_description,
s.closes_at,
s.max_responses,
s.randomize_questions,
s.randomize_options,
s.password_hash,
s.archived_at,
s.created_at,
s.updated_at,
)
.run();
ctx.waitUntil(initDO(env, newId, dupData));
const dupResponse = Response.json(dupData, { status: 201 });
securityHeaders(dupResponse);
return dupResponse;
}
// Nested deletes (sections, questions, respondents) hit the same DO route,
// so only a top-level survey delete may drop the catalog row.
const surveySubPath = pathname.match(SURVEY_ID_PATTERN)?.[2] ?? '';
const isTopLevelSurveyDelete = surveySubPath === '' || surveySubPath === '/';
if (method === 'DELETE' && doResponse.status === 204 && doMatch.surveyId && isTopLevelSurveyDelete) {
// Otherwise a new survey re-using this slug could route to the deleted DO
// via this isolate's stale cache.
invalidateSlugCacheBySurveyId(surveyId);
ctx.waitUntil(
(async () => {
await env.DB.batch([
env.DB.prepare('DELETE FROM survey_tags WHERE survey_id = ?').bind(surveyId),
env.DB.prepare('DELETE FROM surveys WHERE id = ?').bind(surveyId),
]);
})(),
);
}
if (method !== 'GET' && method !== 'HEAD') {
ctx.waitUntil(syncCatalog(doResponse, env.DB, surveyId));
}
const response = cleanResponse(doResponse);
if (isPublicRoute && doMatch.slug) {
const newRespondentId = doResponse.headers.get('X-Respondent-Id');
const isNew = doResponse.headers.get('X-New-Respondent') === 'true';
if (isNew && newRespondentId) {
setRespondentCookie(response.headers, doMatch.slug, newRespondentId, config.cookieSecure);
}
}
// These responses carry credentialed data (session, rid, spw_ cookies), so
// reflecting an arbitrary Origin alongside Allow-Credentials (CWE-942) would
// let any origin — including a sibling *.immich.app — read them.
securityHeaders(response);
const corsOrigin = allowedCredentialedOrigin(request, config);
if (corsOrigin) {
response.headers.set('Access-Control-Allow-Origin', corsOrigin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
response.headers.set('Vary', 'Origin');
}
return response;
},
};
function checkRole(role: UserRole, minRole: UserRole): boolean {
return (ROLE_HIERARCHY[role] ?? 0) >= (ROLE_HIERARCHY[minRole] ?? 0);
}
function allowedCredentialedOrigin(request: Request, config: import('./config').AppConfig): string | null {
const origin = request.headers.get('Origin');
if (!origin) return null;
try {
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;
} catch {
// Malformed redirect URI — treat as no configured origin.
}
}
return null;
}
function getRequiredDORole(method: string, subPath: string): UserRole {
if (method === 'DELETE') {
if (subPath === '/' || subPath === '' || /^\/results\/respondents\//.test(subPath)) {
return 'admin';
}
return 'editor';
}
if (method !== 'GET' && method !== 'HEAD') return 'editor';
if (subPath === '/definition') return 'editor';
return 'viewer';
}
// Auth check for admin routes (outside itty-router). Delegates to the shared
// session-token validator so this path and the authMiddleware (AuthService)
// path can't drift on signature / exp handling.
async function authenticateRequest(
request: Request,
config: import('./config').AppConfig,
): Promise<Response | UserRole> {
const sessionCookie = getCookie(request, 'survey_session');
if (!sessionCookie) {
return Response.json({ error: 'Authentication required' }, { status: 401 });
}
const user = await verifySessionToken(sessionCookie, config.sessionSecret);
if (!user) {
return Response.json({ error: 'Invalid or expired session' }, { status: 401 });
}
return user.role;
}
@@ -0,0 +1,44 @@
import type { IRequest } from 'itty-router';
import { SESSION_COOKIE_NAME, ROLE_HIERARCHY, type UserRole } from '../constants';
import { AuthService, type UserInfo } from '../services/auth.service';
import type { AppContext } from '../config';
import { ServiceError } from '../services/errors';
import { getCookie } from '../cookie';
export interface AuthenticatedRequest extends IRequest {
user?: UserInfo;
}
export function authMiddleware(ctx: AppContext): (request: AuthenticatedRequest) => Promise<void | Response> {
return async (request: AuthenticatedRequest) => {
const url = new URL(request.url);
const path = url.pathname;
if (path.startsWith('/api/s/') || path.startsWith('/api/auth/') || path.startsWith('/api/t/')) {
return;
}
if (!path.startsWith('/api/')) return;
const token = getCookie(request, SESSION_COOKIE_NAME);
if (!token) {
throw new ServiceError('Authentication required', 401);
}
const authService = new AuthService(ctx.config, ctx.db);
const user = await authService.validateSessionToken(token);
if (!user) {
throw new ServiceError('Invalid or expired session', 401);
}
request.user = user;
};
}
export function requireRole(user: UserInfo | undefined, minRole: UserRole): void {
if (!user) throw new ServiceError('Authentication required', 401);
if ((ROLE_HIERARCHY[user.role] ?? 0) < (ROLE_HIERARCHY[minRole] ?? 0)) {
throw new ServiceError('Insufficient permissions', 403);
}
}
@@ -0,0 +1,113 @@
import { Migrator, type Migration, type MigrationProvider, sql, type Kysely } from 'kysely';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Database } from './db';
/**
* Split a SQL script on top-level semicolons. A naive split would break on
* trigger bodies or any string literal containing a semicolon.
*/
function splitSqlStatements(script: string): string[] {
const statements: string[] = [];
let current = '';
let i = 0;
while (i < script.length) {
const ch = script[i];
const next = script[i + 1];
if (ch === "'") {
// Single-quoted string — consume until closing quote, honouring `''` escape
current += ch;
i++;
while (i < script.length) {
const c = script[i];
current += c;
i++;
if (c === "'") {
if (script[i] === "'") {
current += script[i];
i++;
continue;
}
break;
}
}
continue;
}
if (ch === '-' && next === '-') {
// Stop at the newline, don't consume it — line numbers must stay right.
while (i < script.length && script[i] !== '\n') i++;
continue;
}
if (ch === '/' && next === '*') {
i += 2;
while (i < script.length && !(script[i] === '*' && script[i + 1] === '/')) i++;
i += 2;
continue;
}
if (ch === ';') {
const trimmed = current.trim();
if (trimmed) statements.push(trimmed);
current = '';
i++;
continue;
}
current += ch;
i++;
}
const trailing = current.trim();
if (trailing) statements.push(trailing);
return statements;
}
class SqlFileMigrationProvider implements MigrationProvider {
constructor(private migrationsDir: string) {}
async getMigrations(): Promise<Record<string, Migration>> {
const files = await readdir(this.migrationsDir);
const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort();
const migrations: Record<string, Migration> = {};
for (const file of sqlFiles) {
const name = file.replace('.sql', '');
const content = await readFile(join(this.migrationsDir, file), 'utf-8');
migrations[name] = {
async up(db: Kysely<any>) {
for (const stmt of splitSqlStatements(content)) {
await sql.raw(stmt).execute(db);
}
},
};
}
return migrations;
}
}
export async function runMigrations(db: Kysely<Database>, migrationsDir: string): Promise<void> {
const migrator = new Migrator({
db,
provider: new SqlFileMigrationProvider(migrationsDir),
});
const { error, results } = await migrator.migrateToLatest();
if (results) {
for (const result of results) {
if (result.status === 'Success') {
console.log(`Migration ${result.migrationName}: applied`);
} else if (result.status === 'Error') {
console.error(`Migration ${result.migrationName}: failed`);
}
}
}
if (error) {
console.error('Migration failed:', error);
throw error;
}
}
@@ -0,0 +1,50 @@
import type { Kysely } from 'kysely';
import type { Database } from '../db';
export type { AuditLogRow } from '../db';
export class AuditRepository {
constructor(private db: Kysely<Database>) {}
async create(entry: import('../db').AuditLogRow): Promise<void> {
await this.db.insertInto('audit_log').values(entry).execute();
}
async list(offset: number, limit: number): Promise<{ entries: import('../db').AuditLogRow[]; total: number }> {
const countResult = await this.db
.selectFrom('audit_log')
.select(this.db.fn.countAll().as('total'))
.executeTakeFirst();
const entries = await this.db
.selectFrom('audit_log')
.selectAll()
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset)
.execute();
return { entries, total: Number(countResult?.total ?? 0) };
}
async listBySurvey(
surveyId: string,
offset: number,
limit: number,
): Promise<{ entries: import('../db').AuditLogRow[]; total: number }> {
const countResult = await this.db
.selectFrom('audit_log')
.select(this.db.fn.countAll().as('total'))
.where('resource_type', '=', 'survey')
.where('resource_id', '=', surveyId)
.executeTakeFirst();
const entries = await this.db
.selectFrom('audit_log')
.selectAll()
.where('resource_type', '=', 'survey')
.where('resource_id', '=', surveyId)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset)
.execute();
return { entries, total: Number(countResult?.total ?? 0) };
}
}
@@ -0,0 +1,371 @@
import { sql, type Kysely, type SqlBool } from 'kysely';
import type { Database } from '../db';
import { ACTIVE_RESPONDENT_WINDOW_MS, SEARCH_RESULT_LIMIT } from '../constants';
export type { RespondentRow, AnswerRow } from '../db';
export class RespondentRepository {
constructor(private db: Kysely<Database>) {}
async getById(id: string) {
return this.db.selectFrom('respondents').selectAll().where('id', '=', id).executeTakeFirst() ?? null;
}
async create(respondent: {
id: string;
survey_id: string;
ip_address: string | null;
is_complete: number;
created_at: string;
completed_at: string | null;
}): Promise<void> {
await this.db.insertInto('respondents').values(respondent).execute();
}
/**
* Returns true only on the actual 0→1 transition, so callers can gate
* counter/tally updates against duplicate completes (client retry or replay).
*/
async markComplete(id: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await this.db
.updateTable('respondents')
.set({ is_complete: 1, completed_at: now })
.where('id', '=', id)
.where('is_complete', '=', 0)
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
async countBySurveyId(surveyId: string): Promise<{ total: number; completed: number }> {
const row = await this.db
.selectFrom('respondents')
.select(({ fn }) => [
fn.count('id').as('total'),
fn.sum(sql`CASE WHEN is_complete = 1 THEN 1 ELSE 0 END`).as('completed'),
])
.where('survey_id', '=', surveyId)
.executeTakeFirst();
return { total: Number(row?.total ?? 0), completed: Number(row?.completed ?? 0) };
}
async countActiveBySurveyId(surveyId: string): Promise<number> {
const fiveMinAgo = new Date(Date.now() - ACTIVE_RESPONDENT_WINDOW_MS).toISOString();
const row = await this.db
.selectFrom('respondents')
.select(({ fn }) => [fn.count('id').as('count')])
.where('survey_id', '=', surveyId)
.where('is_complete', '=', 0)
.where('created_at', '>', fiveMinAgo)
.executeTakeFirst();
return Number(row?.count ?? 0);
}
async getTimelineData(
surveyId: string,
granularity: 'minute' | 'hour' | 'day',
): Promise<Array<{ period: string; started: number; completed: number }>> {
const sliceLen = granularity === 'day' ? 10 : granularity === 'hour' ? 13 : 16;
const dateExpr = sql`substr(created_at, 1, ${sql.lit(sliceLen)})`;
const completedExpr = sql`substr(completed_at, 1, ${sql.lit(sliceLen)})`;
const startedResults = await this.db
.selectFrom('respondents')
.select([dateExpr.as('period'), ({ fn }) => fn.count('id').as('count')])
.where('survey_id', '=', surveyId)
.groupBy('period')
.orderBy('period')
.execute();
const completedResults = await this.db
.selectFrom('respondents')
.select([completedExpr.as('period'), ({ fn }) => fn.count('id').as('count')])
.where('survey_id', '=', surveyId)
.where('is_complete', '=', 1)
.where('completed_at', 'is not', null)
.groupBy('period')
.orderBy('period')
.execute();
const completedMap = new Map<string, number>(completedResults.map((r) => [String(r.period), Number(r.count)]));
return startedResults.map((r) => ({
period: String(r.period),
started: Number(r.count),
completed: completedMap.get(String(r.period)) ?? 0,
}));
}
async getAnswerDurationsByQuestion(
surveyId: string,
): Promise<Array<{ question_id: string; question_text: string; question_sort: number; answer_ms: number }>> {
const rows = await this.db
.selectFrom('answers as a')
.innerJoin('respondents as r', 'a.respondent_id', 'r.id')
.innerJoin('survey_questions as q', 'a.question_id', 'q.id')
.where('r.survey_id', '=', surveyId)
.where('a.answer_ms', 'is not', null)
.select([
'a.question_id as question_id',
'q.text as question_text',
'q.sort_order as question_sort',
'a.answer_ms as answer_ms',
])
.execute();
return rows.map((r) => ({
question_id: String(r.question_id),
question_text: String(r.question_text),
question_sort: Number(r.question_sort),
answer_ms: Number(r.answer_ms),
}));
}
async getCompletionDurationsSeconds(surveyId: string): Promise<number[]> {
const rows = await this.db
.selectFrom('respondents')
.select([sql<number>`CAST((julianday(completed_at) - julianday(created_at)) * 86400 AS INTEGER)`.as('duration')])
.where('survey_id', '=', surveyId)
.where('is_complete', '=', 1)
.where('completed_at', 'is not', null)
.execute();
return rows.map((r) => Number(r.duration)).filter((d) => d >= 0 && Number.isFinite(d));
}
async deleteWithAnswers(id: string): Promise<void> {
await this.db.deleteFrom('answers').where('respondent_id', '=', id).execute();
await this.db.deleteFrom('respondents').where('id', '=', id).execute();
}
async listBySurveyId(
surveyId: string,
offset: number,
limit: number,
): Promise<{
respondents: Array<{
id: string;
created_at: string;
completed_at: string | null;
is_complete: number;
answer_count: number;
}>;
total: number;
}> {
const countResult = await this.db
.selectFrom('respondents')
.select(({ fn }) => [fn.count('id').as('total')])
.where('survey_id', '=', surveyId)
.executeTakeFirst();
const respondents = await this.db
.selectFrom('respondents as r')
.select([
'r.id',
'r.created_at',
'r.completed_at',
'r.is_complete',
sql<number>`(SELECT COUNT(*) FROM answers a WHERE a.respondent_id = r.id)`.as('answer_count'),
])
.where('r.survey_id', '=', surveyId)
.orderBy('r.created_at', 'desc')
.limit(limit)
.offset(offset)
.execute();
return {
respondents: respondents as Array<{
id: string;
created_at: string;
completed_at: string | null;
is_complete: number;
answer_count: number;
}>,
total: Number(countResult?.total ?? 0),
};
}
}
export class AnswerRepository {
constructor(private db: Kysely<Database>) {}
async getByRespondentId(respondentId: string) {
return this.db.selectFrom('answers').selectAll().where('respondent_id', '=', respondentId).execute();
}
async upsertBatch(
answers: Array<{
respondent_id: string;
question_id: string;
answer: string;
other_text: string | null;
answered_at: string;
answer_ms: number | null;
}>,
): Promise<void> {
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.
const values = sql.join(
answers.map(
(a) =>
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(
this.db,
);
}
async getAllResponsesForSurvey(surveyId: string): Promise<
Array<{
respondent_id: string;
completed_at: string | null;
question_id: string;
answer: string;
other_text: string | null;
}>
> {
return this.db
.selectFrom('answers as a')
.innerJoin('respondents as r', 'a.respondent_id', 'r.id')
.select(['a.respondent_id', 'r.completed_at', 'a.question_id', 'a.answer', 'a.other_text'])
.where('r.survey_id', '=', surveyId)
.where('r.is_complete', '=', 1)
.orderBy('r.completed_at')
.orderBy('a.respondent_id')
.orderBy('a.question_id')
.execute();
}
async getAggregatedResults(
surveyId: string,
): Promise<Array<{ question_id: string; answer: string; other_text: string | null; count: number }>> {
const results = await this.db
.selectFrom('answers as a')
.innerJoin('respondents as r', 'a.respondent_id', 'r.id')
.select(({ fn }) => ['a.question_id', 'a.answer', 'a.other_text', fn.count('a.respondent_id').as('count')])
.where('r.survey_id', '=', surveyId)
.where('r.is_complete', '=', 1)
.groupBy(['a.question_id', 'a.answer', 'a.other_text'])
.orderBy('a.question_id')
.orderBy('count', 'desc')
.execute();
return results.map((r) => ({ ...r, count: Number(r.count) }));
}
async getDropoffData(surveyId: string): Promise<
Array<{
question_id: string;
question_text: string;
sort_order: number;
section_sort_order: number;
answer_count: number;
reached_count: number;
}>
> {
// "reached" must be the distinct respondents who answered ANY question at
// this position or later: conditional and optional questions make raw
// per-question counts non-monotonic (Q4 can beat Q3), so only this tail
// distinct-count yields a non-increasing funnel.
const results = await sql<{
question_id: string;
question_text: string;
sort_order: number;
section_sort_order: number;
answer_count: number;
reached_count: number;
}>`
SELECT
q.id AS question_id,
q.text AS question_text,
q.sort_order,
s.sort_order AS section_sort_order,
COUNT(DISTINCT a.respondent_id) AS answer_count,
(
SELECT COUNT(DISTINCT a2.respondent_id)
FROM answers a2
INNER JOIN survey_questions q2 ON a2.question_id = q2.id
INNER JOIN survey_sections s2 ON q2.section_id = s2.id
INNER JOIN respondents r2 ON a2.respondent_id = r2.id
WHERE r2.survey_id = ${surveyId}
AND (
s2.sort_order > s.sort_order
OR (s2.sort_order = s.sort_order AND q2.sort_order >= q.sort_order)
)
) AS reached_count
FROM survey_questions q
INNER JOIN survey_sections s ON q.section_id = s.id
LEFT JOIN answers a ON q.id = a.question_id
LEFT JOIN respondents r ON a.respondent_id = r.id AND r.survey_id = ${surveyId}
WHERE q.survey_id = ${surveyId}
GROUP BY q.id, q.text, q.sort_order, s.sort_order
ORDER BY s.sort_order, q.sort_order
`.execute(this.db);
return results.rows.map((r) => ({
...r,
sort_order: Number(r.sort_order),
section_sort_order: Number(r.section_sort_order),
answer_count: Number(r.answer_count),
reached_count: Number(r.reached_count),
}));
}
async searchTextAnswers(
surveyId: string,
query: string,
questionId?: string,
offset = 0,
limit = SEARCH_RESULT_LIMIT,
): Promise<{
results: Array<{ respondent_id: string; question_id: string; question_text: string; answer: string }>;
total: number;
}> {
const escaped = query.replace(/[%_]/g, (ch) => `\\${ch}`);
const likeQuery = `%${escaped}%`;
let baseQb = this.db
.selectFrom('answers as a')
.innerJoin('respondents as r', 'a.respondent_id', 'r.id')
.innerJoin('survey_questions as q', 'a.question_id', 'q.id')
.where('r.survey_id', '=', surveyId)
.where(sql<SqlBool>`a.answer LIKE ${likeQuery} ESCAPE '\\'`);
if (questionId) {
baseQb = baseQb.where('a.question_id', '=', questionId);
}
const countResult = await baseQb.select(({ fn }) => [fn.count('a.respondent_id').as('total')]).executeTakeFirst();
const results = await baseQb
.select(['a.respondent_id', 'a.question_id', 'q.text as question_text', 'a.answer'])
.orderBy('a.answered_at', 'desc')
.limit(Math.min(limit, SEARCH_RESULT_LIMIT))
.offset(offset)
.execute();
return {
results,
total: Number(countResult?.total ?? 0),
};
}
async getAnswersForRespondent(respondentId: string): Promise<
Array<{
question_id: string;
question_text: string;
question_type: string;
answer: string;
other_text: string | null;
}>
> {
return this.db
.selectFrom('answers as a')
.innerJoin('survey_questions as q', 'a.question_id', 'q.id')
.select(['a.question_id', 'q.text as question_text', 'q.type as question_type', 'a.answer', 'a.other_text'])
.where('a.respondent_id', '=', respondentId)
.orderBy('q.sort_order')
.execute();
}
}
@@ -0,0 +1,170 @@
import type { Kysely } from 'kysely';
import type { Database } from '../db';
export type { SurveyRow, SectionRow, QuestionRow } from '../db';
export class SurveyRepository {
constructor(private db: Kysely<Database>) {}
async listAll(includeArchived = false): Promise<import('../db').SurveyRow[]> {
let query = this.db.selectFrom('surveys').selectAll().orderBy('created_at', 'desc');
if (!includeArchived) {
query = query.where('archived_at', 'is', null);
}
return query.execute();
}
async listPaginated(opts: {
includeArchived?: boolean;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ surveys: import('../db').SurveyRow[]; total: number }> {
const { includeArchived = false, search, offset = 0, limit = 20 } = opts;
let base = this.db.selectFrom('surveys');
if (!includeArchived) {
base = base.where('archived_at', 'is', null);
}
if (search) {
base = base.where('title', 'like', `%${search}%`);
}
const countResult = await base.select(({ fn }) => [fn.count('id').as('total')]).executeTakeFirst();
const total = Number(countResult?.total ?? 0);
const surveys = await base.selectAll().orderBy('created_at', 'desc').limit(limit).offset(offset).execute();
return { surveys, total };
}
async getById(id: string): Promise<import('../db').SurveyRow | null> {
const result = await this.db.selectFrom('surveys').selectAll().where('id', '=', id).executeTakeFirst();
return result ?? null;
}
async getBySlug(slug: string): Promise<import('../db').SurveyRow | null> {
const result = await this.db.selectFrom('surveys').selectAll().where('slug', '=', slug).executeTakeFirst();
return result ?? null;
}
async create(survey: import('../db').SurveyRow): Promise<void> {
await this.db.insertInto('surveys').values(survey).execute();
}
async update(id: string, fields: Partial<Omit<import('../db').SurveyRow, 'id' | 'created_at'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
await this.db.updateTable('surveys').set(fields).where('id', '=', id).execute();
}
async delete(id: string): Promise<void> {
await this.db.deleteFrom('surveys').where('id', '=', id).execute();
}
}
export class SectionRepository {
constructor(private db: Kysely<Database>) {}
async getBySurveyId(surveyId: string): Promise<import('../db').SectionRow[]> {
return this.db
.selectFrom('survey_sections')
.selectAll()
.where('survey_id', '=', surveyId)
.orderBy('sort_order')
.execute();
}
async getById(id: string): Promise<import('../db').SectionRow | null> {
const result = await this.db.selectFrom('survey_sections').selectAll().where('id', '=', id).executeTakeFirst();
return result ?? null;
}
async create(section: import('../db').SectionRow): Promise<void> {
await this.db.insertInto('survey_sections').values(section).execute();
}
async update(id: string, fields: Partial<Omit<import('../db').SectionRow, 'id' | 'survey_id'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
await this.db.updateTable('survey_sections').set(fields).where('id', '=', id).execute();
}
async delete(id: string): Promise<void> {
await this.db.deleteFrom('survey_sections').where('id', '=', id).execute();
}
async reorder(items: Array<{ id: string; sort_order: number }>): Promise<void> {
await this.db.transaction().execute(async (trx) => {
for (const item of items) {
await trx
.updateTable('survey_sections')
.set({ sort_order: item.sort_order })
.where('id', '=', item.id)
.execute();
}
});
}
async getMaxSortOrder(surveyId: string): Promise<number> {
const row = await this.db
.selectFrom('survey_sections')
.select(this.db.fn.max('sort_order').as('max_order'))
.where('survey_id', '=', surveyId)
.executeTakeFirst();
return (row?.max_order as number | null) ?? -1;
}
}
export class QuestionRepository {
constructor(private db: Kysely<Database>) {}
async getBySurveyId(surveyId: string): Promise<import('../db').QuestionRow[]> {
return this.db
.selectFrom('survey_questions')
.selectAll()
.where('survey_id', '=', surveyId)
.orderBy('sort_order')
.execute();
}
async getById(id: string): Promise<import('../db').QuestionRow | null> {
const result = await this.db.selectFrom('survey_questions').selectAll().where('id', '=', id).executeTakeFirst();
return result ?? null;
}
async create(question: import('../db').QuestionRow): Promise<void> {
await this.db.insertInto('survey_questions').values(question).execute();
}
async update(id: string, fields: Partial<Omit<import('../db').QuestionRow, 'id' | 'survey_id'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
await this.db.updateTable('survey_questions').set(fields).where('id', '=', id).execute();
}
async delete(id: string): Promise<void> {
await this.db.deleteFrom('survey_questions').where('id', '=', id).execute();
}
async deleteBySectionId(sectionId: string): Promise<void> {
await this.db.deleteFrom('survey_questions').where('section_id', '=', sectionId).execute();
}
async reorder(items: Array<{ id: string; sort_order: number }>): Promise<void> {
await this.db.transaction().execute(async (trx) => {
for (const item of items) {
await trx
.updateTable('survey_questions')
.set({ sort_order: item.sort_order })
.where('id', '=', item.id)
.execute();
}
});
}
async getMaxSortOrder(sectionId: string): Promise<number> {
const row = await this.db
.selectFrom('survey_questions')
.select(this.db.fn.max('sort_order').as('max_order'))
.where('section_id', '=', sectionId)
.executeTakeFirst();
return (row?.max_order as number | null) ?? -1;
}
}
@@ -0,0 +1,50 @@
import type { Kysely } from 'kysely';
import type { Database } from '../db';
export type { TagRow } from '../db';
export class TagRepository {
constructor(private db: Kysely<Database>) {}
async listAll(): Promise<import('../db').TagRow[]> {
return this.db.selectFrom('tags').selectAll().orderBy('name').execute();
}
async getById(id: string): Promise<import('../db').TagRow | null> {
const result = await this.db.selectFrom('tags').selectAll().where('id', '=', id).executeTakeFirst();
return result ?? null;
}
async create(tag: import('../db').TagRow): Promise<void> {
await this.db.insertInto('tags').values(tag).execute();
}
async update(id: string, fields: Partial<Omit<import('../db').TagRow, 'id' | 'created_at'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
await this.db.updateTable('tags').set(fields).where('id', '=', id).execute();
}
async delete(id: string): Promise<void> {
await this.db.deleteFrom('tags').where('id', '=', id).execute();
}
async getTagsForSurvey(surveyId: string): Promise<import('../db').TagRow[]> {
return this.db
.selectFrom('tags as t')
.innerJoin('survey_tags as st', 'st.tag_id', 't.id')
.selectAll('t')
.where('st.survey_id', '=', surveyId)
.orderBy('t.name')
.execute();
}
async setTagsForSurvey(surveyId: string, tagIds: string[]): Promise<void> {
await this.db.deleteFrom('survey_tags').where('survey_id', '=', surveyId).execute();
if (tagIds.length > 0) {
await this.db
.insertInto('survey_tags')
.values(tagIds.map((tagId) => ({ survey_id: surveyId, tag_id: tagId })))
.execute();
}
}
}
@@ -0,0 +1,29 @@
import { createAuditService } from '../services/factory';
import { MAX_PAGINATION_LIMIT } from '../constants';
import { requireRole, type AuthenticatedRequest } from '../middleware/auth';
import { getContext } from '../config';
import type { AppRouter } from '../types';
export function registerAuditRoutes(router: AppRouter) {
router.get('/api/audit-log', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = createAuditService(ctx.db);
const url = new URL(request.url);
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, MAX_PAGINATION_LIMIT);
const data = await service.list(offset, limit);
return Response.json(data);
});
router.get('/api/audit-log/survey/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = createAuditService(ctx.db);
const url = new URL(request.url);
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, MAX_PAGINATION_LIMIT);
const data = await service.listBySurvey(request.params.id, offset, limit);
return Response.json(data);
});
}
@@ -0,0 +1,180 @@
import { AuthService } from '../services/auth.service';
import { ServiceError } from '../services/errors';
import { SESSION_COOKIE_NAME, AUTH_STATE_COOKIE_NAME, SESSION_MAX_AGE } from '../constants';
import { getCookie } from '../cookie';
import { constantTimeEqual } from '../utils/crypto';
import { getContext } from '../config';
import type { AppRouter } from '../types';
export function registerAuthRoutes(router: AppRouter) {
router.get('/api/auth/me', async (request) => {
const ctx = getContext(request);
const authService = new AuthService(ctx.config, ctx.db);
const setupComplete = await authService.isSetupComplete();
const passwordEnabled = authService.isPasswordAuthEnabled();
const oidcEnabled = authService.isOidcConfigured();
if (!setupComplete && passwordEnabled) {
return Response.json({
authenticated: false,
needsSetup: true,
needsSetupToken: !!ctx.config.setupToken,
passwordEnabled,
oidcEnabled,
});
}
const sessionToken = getCookie(request, SESSION_COOKIE_NAME);
if (!sessionToken) {
return Response.json({ authenticated: false, passwordEnabled, oidcEnabled });
}
const user = await authService.validateSessionToken(sessionToken);
if (!user) {
return Response.json({ authenticated: false, passwordEnabled, oidcEnabled });
}
return Response.json({ authenticated: true, user, passwordEnabled, oidcEnabled });
});
router.post('/api/auth/setup', async (request) => {
const ctx = getContext(request);
const authService = new AuthService(ctx.config, ctx.db);
if (!authService.isPasswordAuthEnabled()) {
throw new ServiceError('Password authentication is disabled', 400);
}
if (ctx.config.setupToken) {
const provided = request.headers.get('X-Setup-Token') ?? '';
if (!constantTimeEqual(provided, ctx.config.setupToken)) {
throw new ServiceError('A valid setup token is required to claim this instance', 403);
}
}
const body = (await request.json()) as { password?: string };
if (!body.password) throw new ServiceError('Password is required', 400);
await authService.setupAdmin(body.password);
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 }), {
status: 201,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `${SESSION_COOKIE_NAME}=${sessionToken}; Path=/; HttpOnly; ${secure}SameSite=Lax; Max-Age=${SESSION_MAX_AGE}`,
},
});
});
router.post('/api/auth/password-login', async (request) => {
const ctx = getContext(request);
const authService = new AuthService(ctx.config, ctx.db);
if (!authService.isPasswordAuthEnabled()) {
throw new ServiceError('Password authentication is disabled', 400);
}
const body = (await request.json()) as { password?: string };
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 }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `${SESSION_COOKIE_NAME}=${sessionToken}; Path=/; HttpOnly; ${secure}SameSite=Lax; Max-Age=${SESSION_MAX_AGE}`,
},
});
});
router.get('/api/auth/login', async (request) => {
const ctx = getContext(request);
const authService = new AuthService(ctx.config, ctx.db);
if (!authService.isOidcConfigured()) {
throw new ServiceError('OIDC is not configured', 400);
}
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();
const url = new URL(request.url);
const rawReturnTo = url.searchParams.get('returnTo') ?? '/';
// Parse against this origin rather than prefix-checking: the WHATWG parser
// folds `\` to `/`, so `/\evil.com` resolves to `//evil.com` — an open
// redirect off the trusted SSO origin (CWE-601).
let returnTo = '/';
try {
const target = new URL(rawReturnTo, url.origin);
if (target.origin === url.origin) {
returnTo = target.pathname + target.search + target.hash;
}
} catch {
// Invalid URL — fall back to the safe default
}
const authUrl = await authService.getAuthorizationUrl(state, nonce);
const stateData = JSON.stringify({ state, nonce, returnTo });
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
return new Response(null, {
status: 302,
headers: {
Location: authUrl,
'Set-Cookie': `${AUTH_STATE_COOKIE_NAME}=${encodeURIComponent(stateData)}; Path=/; HttpOnly; ${secure}SameSite=Lax; Max-Age=120`,
},
});
});
router.get('/api/auth/callback', async (request) => {
const url = new URL(request.url);
const code = url.searchParams.get('code');
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);
const stateCookie = getCookie(request, AUTH_STATE_COOKIE_NAME);
if (!stateCookie) throw new ServiceError('Missing auth state cookie', 400);
let stateData: { state: string; nonce: string; returnTo: string };
try {
stateData = JSON.parse(decodeURIComponent(stateCookie));
} catch {
throw new ServiceError('Invalid auth state', 400);
}
if (stateData.state !== returnedState) throw new ServiceError('State mismatch', 400);
const ctx = getContext(request);
const authService = new AuthService(ctx.config, ctx.db);
const tokens = await authService.exchangeCode(code);
const user = await authService.validateIdToken(tokens.id_token, stateData.nonce, tokens.access_token);
const sessionToken = await authService.createSessionToken(user);
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
return new Response(null, {
status: 302,
headers: new Headers([
['Location', stateData.returnTo || '/'],
[
'Set-Cookie',
`${SESSION_COOKIE_NAME}=${sessionToken}; Path=/; HttpOnly; ${secure}SameSite=Lax; Max-Age=${SESSION_MAX_AGE}`,
],
['Set-Cookie', `${AUTH_STATE_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`],
]),
});
});
router.post('/api/auth/logout', async () => {
return new Response(null, {
status: 204,
headers: {
'Set-Cookie': `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`,
},
});
});
}
@@ -0,0 +1,23 @@
import { getContext } from '../config';
import { requireRole, type AuthenticatedRequest } from '../middleware/auth';
import { BackupService, type BackupData } from '../services/backup.service';
import type { AppRouter } from '../types';
export function registerBackupRoutes(router: AppRouter) {
router.get('/api/admin/backup', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = new BackupService(ctx.db);
const backup = await service.exportAll();
return Response.json(backup);
});
router.post('/api/admin/restore', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = new BackupService(ctx.db);
const body = (await request.json()) as BackupData;
const result = await service.importAll(body);
return Response.json(result);
});
}
@@ -0,0 +1,185 @@
import { createRespondentService, createSurveyService } from '../services/factory';
import { ServiceError } from '../services/errors';
import { getCookie, getRespondentId, setRespondentCookie, deleteRespondentCookie } from '../cookie';
import { verifyPassword } from '../utils/crypto';
import { mintSurveyPasswordToken, verifySurveyPasswordToken } from '../utils/survey-password-token';
import { PASSWORD_SESSION_MAX_AGE } from '../constants';
import { getContext, type AppContext } from '../config';
import type { SurveyRow } from '../repositories/survey.repository';
import type { AppRouter } from '../types';
async function validatePasswordSession(
request: Request,
slug: string,
survey: SurveyRow,
ctx: AppContext,
): Promise<void> {
if (!survey.password_hash) return;
const token = getCookie(request, `spw_${slug}`);
if (!token || !(await verifySurveyPasswordToken(token, survey.id, survey.password_hash, ctx.config.passwordSecret))) {
throw new ServiceError('Authentication required', 403);
}
}
export function registerRespondentRoutes(router: AppRouter) {
router.get('/api/s/:slug', async (request) => {
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const result = await service.getPublishedSurvey(request.params.slug);
const slug = request.params.slug;
if (result.survey.password_hash) {
const token = getCookie(request, `spw_${slug}`);
const isAuthed = token
? await verifySurveyPasswordToken(
token,
result.survey.id,
result.survey.password_hash,
ctx.config.passwordSecret,
)
: false;
if (!isAuthed) {
const response = Response.json({
survey: {
id: result.survey.id,
title: result.survey.title,
description: result.survey.description,
slug: result.survey.slug,
status: result.survey.status,
welcome_title: result.survey.welcome_title,
welcome_description: result.survey.welcome_description,
},
sections: [],
questions: [],
requiresPassword: true,
});
response.headers.set('Cache-Control', 'private, no-store');
return response;
}
}
const { password_hash: _password_hash, ...safeSurvey } = result.survey;
const response = Response.json({ ...result, survey: safeSurvey });
response.headers.set(
'Cache-Control',
result.survey.password_hash
? 'private, no-store'
: 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400',
);
return response;
});
router.post('/api/s/:slug/auth', async (request) => {
const ctx = getContext(request);
const slug = request.params.slug;
const service = createSurveyService(ctx.db);
const { survey } = await service.getPublishedSurvey(slug);
const body = (await request.json()) as { password: string };
if (!body.password) {
throw new ServiceError('Password is required', 400);
}
if (!survey.password_hash) {
// No password set -- still do a dummy verify to prevent timing side-channel
await verifyPassword(body.password, 'AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
return new Response(null, { status: 204 });
}
const valid = await verifyPassword(body.password, survey.password_hash);
if (!valid) {
throw new ServiceError('Invalid password', 403);
}
const token = await mintSurveyPasswordToken(survey.id, survey.password_hash, ctx.config.passwordSecret);
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
const headers = new Headers();
headers.set(
'Set-Cookie',
`spw_${slug}=${token}; Path=/; HttpOnly; ${secure}SameSite=Lax; Max-Age=${PASSWORD_SESSION_MAX_AGE}`,
);
return new Response(null, { status: 204, headers });
});
router.get('/api/s/:slug/resume', async (request) => {
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const surveyService = createSurveyService(ctx.db);
const slug = request.params.slug;
const { survey } = await surveyService.getPublishedSurvey(slug);
await validatePasswordSession(request, slug, survey, ctx);
const respondentId = getRespondentId(request, slug);
const ip = request.headers.get('CF-Connecting-IP') ?? request.headers.get('x-forwarded-for') ?? 'unknown';
const result = await service.resume(slug, respondentId, ip);
const headers = new Headers();
if (result.isNewRespondent) {
setRespondentCookie(headers, slug, result.respondentId, ctx.config.cookieSecure);
}
const response = Response.json(
{
answers: result.answers,
nextQuestionIndex: result.nextQuestionIndex,
isComplete: result.isComplete,
},
{ headers },
);
response.headers.set('Cache-Control', 'private, no-store');
return response;
});
router.post('/api/s/:slug/answers/batch', async (request) => {
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const surveyService = createSurveyService(ctx.db);
const slug = request.params.slug;
const { survey } = await surveyService.getPublishedSurvey(slug);
await validatePasswordSession(request, slug, survey, ctx);
const respondentId = getRespondentId(request, slug);
if (!respondentId) {
throw new ServiceError('No respondent cookie', 400);
}
const { answers } = (await request.json()) as {
answers: Array<{ questionId: string; value: string; otherText?: string; answerMs?: number }>;
};
await service.submitBatch(slug, respondentId, answers);
return new Response(null, { status: 204 });
});
router.post('/api/s/:slug/complete', async (request) => {
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const surveyService = createSurveyService(ctx.db);
const slug = request.params.slug;
const { survey } = await surveyService.getPublishedSurvey(slug);
await validatePasswordSession(request, slug, survey, ctx);
const respondentId = getRespondentId(request, slug);
if (!respondentId) {
throw new ServiceError('No respondent cookie', 400);
}
await service.complete(slug, respondentId);
return new Response(null, { status: 204 });
});
router.post('/api/s/:slug/reset', async (request) => {
const headers = new Headers();
const ctx = getContext(request);
deleteRespondentCookie(headers, request.params.slug, ctx.config.cookieSecure);
return new Response(null, { status: 204, headers });
});
}
@@ -0,0 +1,128 @@
import { createRespondentService } from '../services/factory';
import { ServiceError } from '../services/errors';
import { MAX_PAGINATION_LIMIT } from '../constants';
import { requireRole, type AuthenticatedRequest } from '../middleware/auth';
import { getContext } from '../config';
import type { AppRouter } from '../types';
export function registerResultRoutes(router: AppRouter) {
router.get('/api/surveys/:id/results', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const results = await service.getResults(request.params.id);
return Response.json(results);
});
router.get('/api/surveys/:id/results/export', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const format = url.searchParams.get('format');
if (format !== 'csv' && format !== 'json') {
throw new ServiceError('format must be csv or json', 400);
}
const result = await service.exportResponses(request.params.id, format);
return new Response(result.data, {
headers: {
'Content-Type': result.contentType,
'Content-Disposition': `attachment; filename="${result.filename}"`,
},
});
});
router.get('/api/surveys/:id/results/live', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const results = await service.getLiveResults(request.params.id);
const etag = `"${results.respondentCounts.completed}-${results.respondentCounts.total}-${results.liveCounts.activeRespondents}-${results.liveCounts.activeViewers}"`;
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === 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');
return response;
});
router.get('/api/surveys/:id/results/timeline', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const raw = url.searchParams.get('granularity');
const granularity: 'minute' | 'hour' | 'day' = raw === 'minute' ? 'minute' : raw === 'hour' ? 'hour' : 'day';
const data = await service.getTimeline(request.params.id, granularity);
return Response.json(data);
});
router.get('/api/surveys/:id/results/completion-times', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getCompletionTimes(request.params.id);
return Response.json(data);
});
router.get('/api/surveys/:id/results/question-timings', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getQuestionTimings(request.params.id);
return Response.json(data);
});
router.get('/api/surveys/:id/results/dropoff', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getDropoff(request.params.id);
return Response.json(data);
});
router.get('/api/surveys/:id/results/respondents', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Math.min(Number(url.searchParams.get('limit')) || 20, MAX_PAGINATION_LIMIT);
const data = await service.listRespondents(request.params.id, offset, limit);
return Response.json(data);
});
router.get('/api/surveys/:id/results/respondents/:rid', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const data = await service.getRespondentDetail(request.params.id, request.params.rid);
return Response.json(data);
});
router.delete('/api/surveys/:id/results/respondents/:rid', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
await service.deleteRespondent(request.params.id, request.params.rid);
return new Response(null, { status: 204 });
});
router.get('/api/surveys/:id/results/search', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createRespondentService(ctx.db);
const url = new URL(request.url);
const query = url.searchParams.get('q') ?? '';
const questionId = url.searchParams.get('questionId') ?? undefined;
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = Math.min(Number(url.searchParams.get('limit')) || 50, MAX_PAGINATION_LIMIT);
const data = await service.searchAnswers(request.params.id, query, questionId, { offset, limit });
return Response.json(data);
});
}
@@ -0,0 +1,192 @@
import { createSurveyService } from '../services/factory';
import { requireRole, type AuthenticatedRequest } from '../middleware/auth';
import { getContext } from '../config';
import { MAX_PAGINATION_LIMIT } from '../constants';
import { toClientSurvey } from '../utils/sanitize';
import type { AppRouter } from '../types';
import type {
CreateSurveyInput,
UpdateSurveyInput,
SurveyDefinition,
CreateSectionInput,
UpdateSectionInput,
CreateQuestionInput,
UpdateQuestionInput,
} from '../services/survey.service';
export function registerSurveyRoutes(router: AppRouter) {
router.get('/api/surveys', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const url = new URL(request.url);
const includeArchived = url.searchParams.get('archived') === 'true';
const search = url.searchParams.get('search')?.trim() || undefined;
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
const limit = Math.min(MAX_PAGINATION_LIMIT, Math.max(1, Number(url.searchParams.get('limit')) || 20));
const result = await service.listSurveysPaginated({ includeArchived, search, offset, limit });
return Response.json({ ...result, surveys: result.surveys.map(toClientSurvey) });
});
router.post('/api/surveys', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as CreateSurveyInput;
const survey = await service.createSurvey(body);
return Response.json(survey, { status: 201 });
});
router.get('/api/surveys/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const result = await service.getSurvey(request.params.id);
return Response.json({ ...result, survey: toClientSurvey(result.survey) });
});
router.put('/api/surveys/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as UpdateSurveyInput;
const survey = await service.updateSurvey(request.params.id, body);
return Response.json(toClientSurvey(survey));
});
router.delete('/api/surveys/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
await service.deleteSurvey(request.params.id);
return new Response(null, { status: 204 });
});
router.put('/api/surveys/:id/publish', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const survey = await service.publishSurvey(request.params.id);
return Response.json(toClientSurvey(survey));
});
router.put('/api/surveys/:id/unpublish', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const survey = await service.unpublishSurvey(request.params.id);
return Response.json(toClientSurvey(survey));
});
router.post('/api/surveys/:id/duplicate', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const result = await service.duplicateSurvey(request.params.id);
return Response.json(result, { status: 201 });
});
router.put('/api/surveys/:id/archive', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const survey = await service.archiveSurvey(request.params.id);
return Response.json(toClientSurvey(survey));
});
router.put('/api/surveys/:id/unarchive', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const survey = await service.unarchiveSurvey(request.params.id);
return Response.json(toClientSurvey(survey));
});
router.get('/api/surveys/:id/definition', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const def = await service.exportDefinition(request.params.id);
return Response.json(def);
});
router.post('/api/surveys/import', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as SurveyDefinition;
const result = await service.importDefinition(body);
return Response.json(result, { status: 201 });
});
router.post('/api/surveys/:id/sections', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as CreateSectionInput;
const section = await service.createSection(request.params.id, body);
return Response.json(section, { status: 201 });
});
router.put('/api/surveys/:id/sections/reorder', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as { items: Array<{ id: string; sort_order: number }> };
await service.reorderSections(request.params.id, body.items);
return new Response(null, { status: 204 });
});
router.put('/api/surveys/:surveyId/sections/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as UpdateSectionInput;
const section = await service.updateSection(request.params.id, body);
return Response.json(section);
});
router.delete('/api/surveys/:surveyId/sections/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
await service.deleteSection(request.params.id);
return new Response(null, { status: 204 });
});
router.post('/api/surveys/:surveyId/sections/:id/questions', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as CreateQuestionInput;
const question = await service.createQuestion(request.params.id, body);
return Response.json(question, { status: 201 });
});
router.put('/api/surveys/:surveyId/questions/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as UpdateQuestionInput;
const question = await service.updateQuestion(request.params.id, body);
return Response.json(question);
});
router.delete('/api/surveys/:surveyId/questions/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
await service.deleteQuestion(request.params.id);
return new Response(null, { status: 204 });
});
router.put('/api/surveys/:surveyId/sections/:id/questions/reorder', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createSurveyService(ctx.db);
const body = (await request.json()) as { items: Array<{ id: string; sort_order: number }> };
await service.reorderQuestions(request.params.id, body.items);
return new Response(null, { status: 204 });
});
}
@@ -0,0 +1,57 @@
import { createTagService } from '../services/factory';
import { requireRole, type AuthenticatedRequest } from '../middleware/auth';
import { getContext } from '../config';
import type { AppRouter } from '../types';
export function registerTagRoutes(router: AppRouter) {
router.get('/api/tags', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createTagService(ctx.db);
const tags = await service.listTags();
return Response.json(tags);
});
router.post('/api/tags', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createTagService(ctx.db);
const body = (await request.json()) as { name: string; color?: string };
const tag = await service.createTag(body);
return Response.json(tag, { status: 201 });
});
router.put('/api/tags/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createTagService(ctx.db);
const body = (await request.json()) as { name?: string; color?: string | null };
const tag = await service.updateTag(request.params.id, body);
return Response.json(tag);
});
router.delete('/api/tags/:id', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'admin');
const ctx = getContext(request);
const service = createTagService(ctx.db);
await service.deleteTag(request.params.id);
return new Response(null, { status: 204 });
});
router.get('/api/surveys/:id/tags', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'viewer');
const ctx = getContext(request);
const service = createTagService(ctx.db);
const tags = await service.getTagsForSurvey(request.params.id);
return Response.json(tags);
});
router.put('/api/surveys/:id/tags', async (request: AuthenticatedRequest) => {
requireRole(request.user, 'editor');
const ctx = getContext(request);
const service = createTagService(ctx.db);
const body = (await request.json()) as { tagIds: string[] };
await service.setTagsForSurvey(request.params.id, body.tagIds ?? []);
return new Response(null, { status: 204 });
});
}
@@ -0,0 +1,10 @@
/**
* Shared by the worker's DO-forwarding check and the DO's own router, which
* strips the same prefixes — one module so the two can't drift.
*/
/** /api/surveys/:surveyId(/...)? */
export const SURVEY_ID_PATTERN = /^\/api\/surveys\/([^/]+)(\/.*)?$/;
/** /api/s/:slug(/...)? */
export const PUBLIC_PATTERN = /^\/api\/s\/([^/]+)(\/.*)?$/;
@@ -0,0 +1,119 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Kysely } from 'kysely';
import { WebSocketServer } from 'ws';
import { configFromProcessEnv, type AppContext } from './config';
import { detectDbType, type Database, type DbConfig } from './db';
import { runMigrations } from './migrator';
import { handlePresenceUpgrade } from './services/in-memory-presence';
import { verifySessionToken } from './utils/session';
import { getCookie } from './cookie';
import { SESSION_COOKIE_NAME, ROLE_HIERARCHY } from './constants';
async function createDatabase(dbConfig: DbConfig): Promise<Kysely<Database>> {
if (dbConfig.type === 'sqlite') {
const BetterSqlite3 = (await import('better-sqlite3')).default;
const { SqliteDialect } = await import('kysely');
return new Kysely<Database>({
dialect: new SqliteDialect({ database: new BetterSqlite3(dbConfig.url ?? ':memory:') }),
});
}
if (dbConfig.type === 'postgres') {
const { Pool } = await import('pg');
const { PostgresDialect } = await import('kysely');
return new Kysely<Database>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: dbConfig.url }) }),
});
}
throw new Error(`Unsupported database type for self-hosted mode: ${dbConfig.type}`);
}
const __dirname = dirname(fileURLToPath(import.meta.url));
async function main() {
const config = configFromProcessEnv();
const dbType = detectDbType(process.env.DATABASE_URL);
const db = await createDatabase({
type: dbType,
url: process.env.DATABASE_URL ?? join(process.cwd(), 'data', 'survey.db'),
});
const migrationsDir = join(__dirname, '..', 'migrations');
try {
await runMigrations(db, migrationsDir);
} catch (e) {
console.error('Migration error:', e);
}
const ctx: AppContext = { db, config };
const { createRouter } = await import('./index');
const router = createRouter(ctx);
const app = new Hono();
const staticDir = process.env.STATIC_DIR;
if (staticDir) {
app.use('/*', serveStatic({ root: staticDir }));
}
app.all('/api/*', async (c) => {
const response = await router.fetch(c.req.raw);
return new Response(response.body, response);
});
if (staticDir) {
app.get('*', serveStatic({ root: staticDir, path: 'index.html' }));
}
const port = Number(process.env.PORT ?? 3000);
console.log(`Survey server starting on port ${port}`);
console.log(`Database: ${dbType} (${process.env.DATABASE_URL ?? 'default SQLite'})`);
const server = serve({ fetch: app.fetch, port });
console.log(`Server ready at http://localhost:${port}`);
const wss = new WebSocketServer({ noServer: true });
(server as import('node:http').Server).on('upgrade', (req, socket, head) => {
void (async () => {
const url = new URL(req.url ?? '', `http://localhost:${port}`);
const wsMatch = url.pathname.match(/^\/api\/s\/([^/]+)\/ws$/);
if (!wsMatch) {
socket.destroy();
return;
}
const slug = wsMatch[1];
const type = url.searchParams.get('type');
if (type !== 'viewer' && type !== 'respondent') {
socket.destroy();
return;
}
if (type === 'viewer') {
const cookieHeader = req.headers.cookie ?? '';
const token = getCookie({ headers: { get: () => cookieHeader } }, SESSION_COOKIE_NAME);
const user = token ? await verifySessionToken(token, config.sessionSecret) : null;
if (!user || (ROLE_HIERARCHY[user.role] ?? 0) < ROLE_HIERARCHY.viewer) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
handlePresenceUpgrade(ws as any, slug, type);
});
})();
});
}
main().catch((e) => {
console.error('Failed to start server:', e);
process.exit(1);
});
@@ -0,0 +1,41 @@
import { AuditRepository, type AuditLogRow } from '../repositories/audit.repository';
export interface AuditLogInput {
userSub: string;
userEmail: string;
action: string;
resourceType: string;
resourceId?: string;
details?: string;
ipAddress?: string;
}
export class AuditService {
constructor(private audit: AuditRepository) {}
async log(input: AuditLogInput): Promise<void> {
await this.audit.create({
id: crypto.randomUUID(),
user_sub: input.userSub,
user_email: input.userEmail,
action: input.action,
resource_type: input.resourceType,
resource_id: input.resourceId ?? null,
details: input.details ?? null,
ip_address: input.ipAddress ?? null,
created_at: new Date().toISOString(),
});
}
async list(offset: number, limit: number): Promise<{ entries: AuditLogRow[]; total: number }> {
return this.audit.list(offset, limit);
}
async listBySurvey(
surveyId: string,
offset: number,
limit: number,
): Promise<{ entries: AuditLogRow[]; total: number }> {
return this.audit.listBySurvey(surveyId, offset, limit);
}
}
@@ -0,0 +1,290 @@
import type { Kysely } from 'kysely';
import type { Database } from '../db';
import type { AppConfig } from '../config';
import { ServiceError } from './errors';
import { hashPassword, verifyPassword } from '../utils/crypto';
import { verifySessionToken } from '../utils/session';
export interface UserInfo {
sub: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
}
interface OidcConfig {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
issuer: string;
userinfo_endpoint?: string;
}
let cachedOidcConfig: { data: OidcConfig; fetchedAt: number } | null = null;
let cachedJwks: { data: { keys: Array<JsonWebKey & { kid?: string; alg?: string }> }; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 60 * 60 * 1000;
export class AuthService {
constructor(
private config: AppConfig,
private db: Kysely<Database>,
) {}
async isSetupComplete(): Promise<boolean> {
const row = await this.db
.selectFrom('admin_credentials')
.select('id')
.where('id', '=', 'default')
.executeTakeFirst();
return !!row;
}
async setupAdmin(password: string): Promise<void> {
if (await this.isSetupComplete()) {
throw new ServiceError('Admin account already exists', 400);
}
if (!password || password.length < 8) {
throw new ServiceError('Password must be at least 8 characters', 400);
}
const hash = await hashPassword(password);
await this.db
.insertInto('admin_credentials')
.values({ id: 'default', password_hash: hash, created_at: new Date().toISOString() })
.execute();
}
async passwordLogin(password: string): Promise<UserInfo> {
const row = await this.db
.selectFrom('admin_credentials')
.select('password_hash')
.where('id', '=', 'default')
.executeTakeFirst();
if (!row) {
throw new ServiceError('Admin account not set up', 400);
}
const valid = await verifyPassword(password, row.password_hash);
if (!valid) {
throw new ServiceError('Invalid password', 401);
}
return {
sub: 'local-admin',
email: 'admin@local',
name: 'Admin',
role: 'admin',
};
}
isOidcConfigured(): boolean {
return !!(this.config.oidc.issuer && this.config.oidc.clientId && this.config.oidc.issuer !== 'disabled');
}
isPasswordAuthEnabled(): boolean {
return !this.config.disablePasswordAuth;
}
async getOidcConfig(): Promise<OidcConfig> {
if (cachedOidcConfig && Date.now() - cachedOidcConfig.fetchedAt < CACHE_TTL_MS) {
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);
const data = (await res.json()) as OidcConfig;
cachedOidcConfig = { data, fetchedAt: Date.now() };
return data;
}
async getJwks(): Promise<{ keys: Array<JsonWebKey & { kid?: string; alg?: string }> }> {
if (cachedJwks && Date.now() - cachedJwks.fetchedAt < CACHE_TTL_MS) {
return cachedJwks.data;
}
const config = await this.getOidcConfig();
const res = await fetch(config.jwks_uri);
if (!res.ok) throw new ServiceError('Failed to fetch JWKS', 500);
const data = (await res.json()) as { keys: Array<JsonWebKey & { kid?: string; alg?: string }> };
cachedJwks = { data, fetchedAt: Date.now() };
return data;
}
async getAuthorizationUrl(state: string, nonce: string): Promise<string> {
const config = await this.getOidcConfig();
const params = new URLSearchParams({
client_id: this.config.oidc.clientId,
response_type: 'code',
scope: 'openid email profile',
redirect_uri: this.config.oidc.redirectUri,
state,
nonce,
});
return `${config.authorization_endpoint}?${params}`;
}
async exchangeCode(code: string): Promise<{ id_token: string; access_token: string }> {
const config = await this.getOidcConfig();
const res = await fetch(config.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.oidc.clientId,
client_secret: this.config.oidc.clientSecret,
code,
redirect_uri: this.config.oidc.redirectUri,
}),
});
if (!res.ok) {
throw new ServiceError('Token exchange failed', 500);
}
return res.json() as Promise<{ id_token: string; access_token: string }>;
}
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown> | null> {
try {
const config = await this.getOidcConfig();
if (!config.userinfo_endpoint) return null;
const res = await fetch(config.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
} catch {
return null;
}
}
async validateIdToken(idToken: string, nonce: string, accessToken?: string): Promise<UserInfo> {
const parts = idToken.split('.');
if (parts.length !== 3) throw new ServiceError('Invalid ID token format', 400);
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/'))) as {
kid?: string;
alg?: string;
};
if (header.alg !== 'RS256') {
throw new ServiceError(`Unsupported token algorithm: ${header.alg}`, 400);
}
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>;
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 (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) {
throw new ServiceError('Token expired', 400);
}
// A `kid` missing from the cached JWKS means the IdP may have rotated keys
// since we fetched — bust the cache and retry once before failing.
let jwks = await this.getJwks();
let key = header.kid ? jwks.keys.find((k) => k.kid === header.kid) : jwks.keys[0];
if (!key && header.kid) {
cachedJwks = null;
jwks = await this.getJwks();
key = jwks.keys.find((k) => k.kid === header.kid);
}
if (!key) throw new ServiceError('No matching signing key found', 400);
const signingKey = await crypto.subtle.importKey(
'jwk',
key,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify'],
);
const signatureValid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
signingKey,
Uint8Array.from(atob(parts[2].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
);
if (!signatureValid) throw new ServiceError('Invalid token signature', 400);
let claims: Record<string, unknown> = payload;
if (accessToken) {
const userinfo = await this.fetchUserInfo(accessToken);
if (userinfo && userinfo.sub === payload.sub) {
claims = { ...payload, ...userinfo };
}
}
const role = this.extractRole(claims);
return {
sub: payload.sub as string,
email: (claims.email as string) ?? '',
name: (claims.name as string) ?? (claims.preferred_username as string) ?? '',
role,
};
}
private extractRole(claims: Record<string, unknown>): 'admin' | 'editor' | 'viewer' {
const claimPath = this.config.oidc.roleClaim;
let value: unknown = claims;
// Support nested claims like "realm_access.roles"
for (const part of claimPath.split('.')) {
if (value && typeof value === 'object') {
value = (value as Record<string, unknown>)[part];
} else {
return 'viewer';
}
}
if (Array.isArray(value)) {
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';
}
return 'viewer';
}
async createSessionToken(user: UserInfo): Promise<string> {
if (!this.config.sessionSecret) {
throw new ServiceError('SESSION_SECRET is not configured', 500);
}
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = btoa(
JSON.stringify({
sub: user.sub,
email: user.email,
name: user.name,
role: user.role,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 8 * 60 * 60,
}),
);
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(this.config.sessionSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
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, '_')
.replace(/=+$/, '');
return `${header}.${payload}.${signature}`;
}
async validateSessionToken(token: string): Promise<UserInfo | null> {
return verifySessionToken(token, this.config.sessionSecret);
}
}
@@ -0,0 +1,201 @@
import type { Kysely } from 'kysely';
import type { Database } from '../db';
const REQUIRED_TABLES = ['surveys', 'tags', 'survey_tags', 'audit_log', 'admin_credentials'] as const;
/**
* Column allowlist per table: restore rejects any row carrying a column that
* isn't listed here, so a malformed or malicious backup can't reach the schema.
*/
const TABLE_COLUMNS: Record<string, readonly string[]> = {
surveys: [
'id',
'title',
'description',
'slug',
'status',
'welcome_title',
'welcome_description',
'thank_you_title',
'thank_you_description',
'closes_at',
'max_responses',
'randomize_questions',
'randomize_options',
'password_hash',
'archived_at',
'created_at',
'updated_at',
],
survey_sections: ['id', 'survey_id', 'title', 'description', 'sort_order'],
survey_questions: [
'id',
'survey_id',
'section_id',
'text',
'description',
'type',
'options',
'required',
'has_other',
'other_prompt',
'max_length',
'placeholder',
'sort_order',
'conditional',
'config',
],
respondents: ['id', 'survey_id', 'ip_address', 'is_complete', 'created_at', 'completed_at'],
answers: ['respondent_id', 'question_id', 'answer', 'other_text', 'answered_at', 'answer_ms'],
tags: ['id', 'name', 'color', 'created_at'],
survey_tags: ['survey_id', 'tag_id'],
audit_log: [
'id',
'user_sub',
'user_email',
'action',
'resource_type',
'resource_id',
'details',
'ip_address',
'created_at',
],
admin_credentials: ['id', 'password_hash', 'created_at'],
};
function validateRow(table: string, row: unknown, index: number): Record<string, unknown> {
if (!row || typeof row !== 'object' || Array.isArray(row)) {
throw new Error(`Backup ${table}[${index}]: row must be an object`);
}
const record = row as Record<string, unknown>;
const allowed = new Set(TABLE_COLUMNS[table] ?? []);
for (const key of Object.keys(record)) {
if (!allowed.has(key)) {
throw new Error(`Backup ${table}[${index}]: unknown column '${key}'`);
}
}
return record;
}
export interface BackupData {
version: number;
exportedAt: string;
data: {
surveys: unknown[];
tags: unknown[];
survey_tags: unknown[];
audit_log: unknown[];
admin_credentials: unknown[];
survey_sections?: unknown[];
survey_questions?: unknown[];
respondents?: unknown[];
answers?: unknown[];
};
}
export class BackupService {
constructor(private db: Kysely<Database>) {}
async exportAll(): Promise<BackupData> {
const [surveys, sections, questions, respondents, answers, tags, surveyTags, auditLog, adminCredentials] =
await Promise.all([
this.db.selectFrom('surveys').selectAll().execute(),
this.db.selectFrom('survey_sections').selectAll().execute(),
this.db.selectFrom('survey_questions').selectAll().execute(),
this.db.selectFrom('respondents').selectAll().execute(),
this.db.selectFrom('answers').selectAll().execute(),
this.db.selectFrom('tags').selectAll().execute(),
this.db.selectFrom('survey_tags').selectAll().execute(),
this.db.selectFrom('audit_log').selectAll().execute(),
this.db.selectFrom('admin_credentials').selectAll().execute(),
]);
return {
version: 1,
exportedAt: new Date().toISOString(),
data: {
surveys,
survey_sections: sections,
survey_questions: questions,
respondents,
answers,
tags,
survey_tags: surveyTags,
audit_log: auditLog,
admin_credentials: adminCredentials,
},
};
}
async importAll(backup: BackupData): Promise<{ counts: Record<string, number> }> {
if (!backup || typeof backup !== 'object') {
throw new Error('Invalid backup data');
}
if (backup.version !== 1) {
throw new Error(`Unsupported backup version: ${backup.version}`);
}
if (!backup.data || typeof backup.data !== 'object') {
throw new Error('Backup data is missing');
}
for (const table of REQUIRED_TABLES) {
if (!Array.isArray(backup.data[table])) {
throw new Error(`Backup is missing required table: ${table}`);
}
}
const counts: Record<string, number> = {};
const tables: Array<[string, string]> = [
['surveys', 'surveys'],
['survey_sections', 'survey_sections'],
['survey_questions', 'survey_questions'],
['tags', 'tags'],
['survey_tags', 'survey_tags'],
['respondents', 'respondents'],
['answers', 'answers'],
['audit_log', 'audit_log'],
['admin_credentials', 'admin_credentials'],
];
// Validate every row before touching the live DB: on D1 the Kysely
// `transaction()` wrapper isn't a real transaction (no BEGIN/COMMIT over
// the HTTP binding), so a mid-restore failure would leave it half-wiped.
const validatedRows: Record<string, Record<string, unknown>[]> = {};
for (const [key, table] of tables) {
const rows = (backup.data as Record<string, unknown[]>)[key];
if (!Array.isArray(rows)) {
validatedRows[key] = [];
continue;
}
validatedRows[key] = rows.map((row, i) => validateRow(table, row, i));
}
await this.db.transaction().execute(async (trx) => {
// Delete in reverse FK order
await trx.deleteFrom('answers').execute();
await trx.deleteFrom('respondents').execute();
await trx.deleteFrom('survey_tags').execute();
await trx.deleteFrom('survey_questions').execute();
await trx.deleteFrom('survey_sections').execute();
await trx.deleteFrom('surveys').execute();
await trx.deleteFrom('tags').execute();
await trx.deleteFrom('audit_log').execute();
await trx.deleteFrom('admin_credentials').execute();
// Insert in FK order
for (const [key, table] of tables) {
const rows = validatedRows[key];
for (const row of rows) {
await trx
.insertInto(table as any)
.values(row as any)
.execute();
}
counts[table] = rows.length;
}
});
return { counts };
}
}
@@ -0,0 +1,9 @@
export class ServiceError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = 'ServiceError';
}
}
@@ -0,0 +1,31 @@
import type { Kysely } from 'kysely';
import type { Database } from '../db';
import { SurveyService } from './survey.service';
import { RespondentService } from './respondent.service';
import { TagService } from './tag.service';
import { AuditService } from './audit.service';
import { SurveyRepository, SectionRepository, QuestionRepository } from '../repositories/survey.repository';
import { RespondentRepository, AnswerRepository } from '../repositories/respondent.repository';
import { TagRepository } from '../repositories/tag.repository';
import { AuditRepository } from '../repositories/audit.repository';
export function createSurveyService(db: Kysely<Database>): SurveyService {
return new SurveyService(new SurveyRepository(db), new SectionRepository(db), new QuestionRepository(db));
}
export function createRespondentService(db: Kysely<Database>): RespondentService {
return new RespondentService(
new RespondentRepository(db),
new AnswerRepository(db),
new SurveyRepository(db),
new QuestionRepository(db),
);
}
export function createTagService(db: Kysely<Database>): TagService {
return new TagService(new TagRepository(db));
}
export function createAuditService(db: Kysely<Database>): AuditService {
return new AuditService(new AuditRepository(db));
}
@@ -0,0 +1,97 @@
import type { WebSocket } from 'ws';
const BROADCAST_INTERVAL_MS = 5000;
interface SurveyRoom {
viewers: Set<WebSocket>;
respondents: Set<WebSocket>;
broadcastTimer: ReturnType<typeof setTimeout> | undefined;
}
const rooms = new Map<string, SurveyRoom>();
function getRoom(slug: string): SurveyRoom {
let room = rooms.get(slug);
if (!room) {
room = { viewers: new Set(), respondents: new Set(), broadcastTimer: undefined };
rooms.set(slug, room);
}
return room;
}
function countsMessage(room: SurveyRoom): string {
return JSON.stringify({
type: 'push',
event: 'counts',
data: {
activeViewers: room.viewers.size,
activeRespondents: room.respondents.size,
},
});
}
function scheduleBroadcast(room: SurveyRoom) {
if (room.broadcastTimer) return;
room.broadcastTimer = setTimeout(() => {
room.broadcastTimer = undefined;
const msg = countsMessage(room);
for (const ws of room.viewers) {
try {
ws.send(msg);
} catch {
// will trigger close
}
}
}, BROADCAST_INTERVAL_MS);
}
function cleanupRoom(slug: string, room: SurveyRoom) {
if (room.viewers.size === 0 && room.respondents.size === 0) {
clearTimeout(room.broadcastTimer);
rooms.delete(slug);
}
}
export function handlePresenceUpgrade(ws: WebSocket, slug: string, type: 'viewer' | 'respondent'): void {
const room = getRoom(slug);
const set = type === 'viewer' ? room.viewers : room.respondents;
set.add(ws);
ws.send(countsMessage(room));
scheduleBroadcast(room);
ws.on('message', (raw) => {
let msg: { type?: string; requestId?: string } | undefined;
try {
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
} catch {
return;
}
if (msg?.type === 'request' && msg.requestId) {
try {
ws.send(
JSON.stringify({
type: 'response',
requestId: msg.requestId,
error: 'WebSocket command operations are not supported in self-hosted mode',
}),
);
} catch {
// Socket already closing — nothing to do.
}
}
});
ws.on('close', () => {
set.delete(ws);
scheduleBroadcast(room);
cleanupRoom(slug, room);
});
ws.on('error', () => {
set.delete(ws);
scheduleBroadcast(room);
cleanupRoom(slug, room);
});
}
@@ -0,0 +1,605 @@
import { RespondentRepository, AnswerRepository, type AnswerRow } from '../repositories/respondent.repository';
import {
SurveyRepository,
QuestionRepository,
type QuestionRow,
type SurveyRow,
} from '../repositories/survey.repository';
import { ServiceError } from './errors';
import { BATCH_ANSWER_LIMIT, COMPLETION_TIME_BUCKETS, clampAnswerMs, percentile } from '../constants';
import { validateAnswer, type QuestionSpec } from '../../../shared/answer-validation';
export interface ResumeResult {
answers: Record<string, { value: string; otherText?: string }>;
nextQuestionIndex: number;
isComplete: boolean;
respondentId: string;
isNewRespondent: boolean;
}
export interface BatchAnswerInput {
questionId: string;
value: string;
otherText?: string;
answerMs?: number;
}
export interface AggregatedResult {
questionId: string;
answers: Array<{ value: string; otherText: string | null; count: number }>;
}
export class RespondentService {
constructor(
private respondents: RespondentRepository,
private answers: AnswerRepository,
private surveys: SurveyRepository,
private questions: QuestionRepository,
) {}
private checkSurveyClosed(survey: SurveyRow): void {
if (survey.closes_at && new Date(survey.closes_at) < new Date()) {
throw new ServiceError('This survey has closed', 403);
}
}
private async checkResponseLimit(survey: SurveyRow): Promise<void> {
if (survey.max_responses) {
const counts = await this.respondents.countBySurveyId(survey.id);
if (counts.completed >= survey.max_responses) {
throw new ServiceError('This survey has reached its maximum number of responses', 403);
}
}
}
async resume(
slug: string,
respondentId: string | undefined,
ipAddress: string,
knownSurvey?: SurveyRow,
): Promise<ResumeResult> {
const survey = knownSurvey ?? (await this.surveys.getBySlug(slug));
if (!survey || survey.status !== 'published') {
throw new ServiceError('Survey not found', 404);
}
this.checkSurveyClosed(survey);
await this.checkResponseLimit(survey);
if (!respondentId) {
return this.createNewRespondent(survey.id, ipAddress);
}
const respondent = await this.respondents.getById(respondentId);
if (!respondent || respondent.survey_id !== survey.id) {
return this.createNewRespondent(survey.id, ipAddress);
}
if (respondent.is_complete) {
return {
answers: {},
nextQuestionIndex: 0,
isComplete: true,
respondentId: respondent.id,
isNewRespondent: false,
};
}
const answerRows = await this.answers.getByRespondentId(respondentId);
const answersMap: Record<string, { value: string; otherText?: string }> = {};
for (const row of answerRows) {
answersMap[row.question_id] = {
value: row.answer,
...(row.other_text ? { otherText: row.other_text } : {}),
};
}
const questions = await this.questions.getBySurveyId(survey.id);
const nextQuestionIndex = this.findResumeIndex(questions, answersMap);
return {
answers: answersMap,
nextQuestionIndex,
isComplete: false,
respondentId: respondent.id,
isNewRespondent: false,
};
}
async submitBatch(
slug: string,
respondentId: string,
inputs: BatchAnswerInput[],
knownSurvey?: SurveyRow,
): Promise<void> {
if (!inputs || !Array.isArray(inputs) || inputs.length === 0 || inputs.length > BATCH_ANSWER_LIMIT) {
throw new ServiceError(`Invalid answers payload: must be 1-${BATCH_ANSWER_LIMIT} answers`, 400);
}
const respondent = await this.respondents.getById(respondentId);
if (!respondent) {
throw new ServiceError('Respondent not found', 404);
}
const survey = knownSurvey ?? (await this.surveys.getBySlug(slug));
if (!survey || respondent.survey_id !== survey.id) {
throw new ServiceError('Respondent does not belong to this survey', 403);
}
// Completion is terminal: without this guard the upsert would let a
// completed respondent silently rewrite their answers and answer_ms.
if (respondent.is_complete) {
throw new ServiceError('Survey already completed', 409);
}
this.checkSurveyClosed(survey);
const surveyQuestions = await this.questions.getBySurveyId(survey.id);
const questionMap = new Map(surveyQuestions.map((sq) => [sq.id, sq]));
for (const input of inputs) {
const sq = questionMap.get(input.questionId);
if (!sq) throw new ServiceError(`Invalid question ID: ${input.questionId}`, 400);
const spec: QuestionSpec = {
type: sq.type,
required: sq.required === 1,
options: sq.options ? JSON.parse(sq.options) : undefined,
hasOther: sq.has_other === 1,
maxLength: sq.max_length ?? undefined,
config: sq.config ? JSON.parse(sq.config) : undefined,
};
const error = validateAnswer(spec, input.value, input.otherText);
if (error) throw new ServiceError(error, 400);
}
const now = new Date().toISOString();
const answerRows: AnswerRow[] = inputs.map((a) => ({
respondent_id: respondentId,
question_id: a.questionId,
answer: a.value,
other_text: a.otherText ?? null,
answered_at: now,
answer_ms: clampAnswerMs(a.answerMs),
}));
await this.answers.upsertBatch(answerRows);
}
/**
* Returns true only on the 0→1 transition; a duplicate `complete` returns
* false so callers don't double-count it.
*/
async complete(slug: string, respondentId: string, knownSurvey?: SurveyRow): Promise<boolean> {
const respondent = await this.respondents.getById(respondentId);
if (!respondent) {
throw new ServiceError('Respondent not found', 404);
}
const survey = knownSurvey ?? (await this.surveys.getBySlug(slug));
if (!survey || respondent.survey_id !== survey.id) {
throw new ServiceError('Respondent does not belong to this survey', 403);
}
return this.respondents.markComplete(respondentId);
}
async deleteRespondent(surveyId: string, respondentId: string): Promise<void> {
const respondent = await this.respondents.getById(respondentId);
if (!respondent || respondent.survey_id !== surveyId) {
throw new ServiceError('Respondent not found', 404);
}
await this.respondents.deleteWithAnswers(respondentId);
}
async getResults(surveyId: string): Promise<{
respondentCounts: { total: number; completed: number };
results: AggregatedResult[];
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) {
throw new ServiceError('Survey not found', 404);
}
const [respondentCounts, rawResults] = await Promise.all([
this.respondents.countBySurveyId(surveyId),
this.answers.getAggregatedResults(surveyId),
]);
const grouped = new Map<string, AggregatedResult>();
for (const row of rawResults) {
if (!grouped.has(row.question_id)) {
grouped.set(row.question_id, { questionId: row.question_id, answers: [] });
}
grouped.get(row.question_id)?.answers.push({
value: row.answer,
otherText: row.other_text,
count: row.count,
});
}
return {
respondentCounts,
results: [...grouped.values()],
};
}
async exportResponses(
surveyId: string,
format: 'csv' | 'json',
): Promise<{ data: ReadableStream<Uint8Array>; contentType: string; filename: string }> {
const survey = await this.surveys.getById(surveyId);
if (!survey) {
throw new ServiceError('Survey not found', 404);
}
const questions = await this.questions.getBySurveyId(surveyId);
const responses = await this.answers.getAllResponsesForSurvey(surveyId);
// Rows must be collapsed per respondent before any record can be emitted,
// so the grouping is buffered; only the output below is streamed.
const respondentMap = new Map<
string,
{ completedAt: string | null; answers: Map<string, { value: string; otherText: string | null }> }
>();
for (const row of responses) {
let entry = respondentMap.get(row.respondent_id);
if (!entry) {
entry = { completedAt: row.completed_at, answers: new Map() };
respondentMap.set(row.respondent_id, entry);
}
entry.answers.set(row.question_id, { value: row.answer, otherText: row.other_text });
}
const encoder = new TextEncoder();
const filenameBase = `${survey.slug ?? survey.id}-responses`;
if (format === 'json') {
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
controller.enqueue(encoder.encode('[\n'));
let first = true;
for (const [respondentId, entry] of respondentMap) {
const record = {
respondentId,
completedAt: entry.completedAt,
answers: Object.fromEntries(entry.answers),
};
controller.enqueue(encoder.encode((first ? '' : ',\n') + JSON.stringify(record, null, 2)));
first = false;
}
controller.enqueue(encoder.encode('\n]\n'));
controller.close();
},
});
return { data: stream, contentType: 'application/json', filename: `${filenameBase}.json` };
}
const questionColumns = questions.map((q) => ({ id: q.id, text: q.text, hasOther: q.has_other === 1 }));
const headers = ['respondent_id', 'completed_at'];
for (const col of questionColumns) {
headers.push(this.csvSafe(col.text));
if (col.hasOther) headers.push(this.csvSafe(col.text) + '_other');
}
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
controller.enqueue(encoder.encode(headers.map((h) => `"${h}"`).join(',') + '\n'));
for (const [respondentId, entry] of respondentMap) {
const row: string[] = [`"${respondentId}"`, `"${entry.completedAt ?? ''}"`];
for (const col of questionColumns) {
const answer = entry.answers.get(col.id);
row.push(`"${this.csvSafe(answer?.value ?? '')}"`);
if (col.hasOther) row.push(`"${this.csvSafe(answer?.otherText ?? '')}"`);
}
controller.enqueue(encoder.encode(row.join(',') + '\n'));
}
controller.close();
},
});
return { data: stream, contentType: 'text/csv', filename: `${filenameBase}.csv` };
}
async getTimeline(
surveyId: string,
granularity: 'minute' | 'hour' | 'day',
): Promise<Array<{ period: string; started: number; completed: number }>> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
return this.respondents.getTimelineData(surveyId, granularity);
}
async getQuestionTimings(surveyId: string): Promise<
Array<{
questionId: string;
questionText: string;
sampleSize: number;
meanMs: number | null;
medianMs: number | null;
p5Ms: number | null;
p25Ms: number | null;
p75Ms: number | null;
p95Ms: number | null;
minMs: number | null;
maxMs: number | null;
}>
> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const rows = await this.respondents.getAnswerDurationsByQuestion(surveyId);
if (rows.length === 0) return [];
const byQ = new Map<string, { questionId: string; questionText: string; sort: number; durations: number[] }>();
for (const r of rows) {
let entry = byQ.get(r.question_id);
if (!entry) {
entry = { questionId: r.question_id, questionText: r.question_text, sort: r.question_sort, durations: [] };
byQ.set(r.question_id, entry);
}
entry.durations.push(r.answer_ms);
}
return [...byQ.values()]
.sort((a, b) => a.sort - b.sort)
.map((q) => {
const sorted = [...q.durations].sort((a, b) => a - b);
const n = sorted.length;
const sum = sorted.reduce((acc, v) => acc + v, 0);
return {
questionId: q.questionId,
questionText: q.questionText,
sampleSize: n,
meanMs: n > 0 ? Math.round(sum / n) : null,
medianMs: percentile(sorted, 50),
p5Ms: percentile(sorted, 5),
p25Ms: percentile(sorted, 25),
p75Ms: percentile(sorted, 75),
p95Ms: percentile(sorted, 95),
minMs: n > 0 ? sorted[0] : null,
maxMs: n > 0 ? sorted[n - 1] : null,
};
});
}
async getCompletionTimes(surveyId: string): Promise<{
count: number;
median: number | null;
mean: number | null;
p25: number | null;
p75: number | null;
min: number | null;
max: number | null;
buckets: Array<{ label: string; minSeconds: number; maxSeconds: number | null; count: number }>;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const durations = await this.respondents.getCompletionDurationsSeconds(surveyId);
const count = durations.length;
const buckets = COMPLETION_TIME_BUCKETS.map((b) => ({ ...b, count: 0 }));
for (const d of durations) {
for (const b of buckets) {
if (d >= b.minSeconds && (b.maxSeconds === null || d < b.maxSeconds)) {
b.count += 1;
break;
}
}
}
if (count === 0) {
return { count: 0, median: null, mean: null, p25: null, p75: null, min: null, max: null, buckets };
}
const sorted = [...durations].sort((a, b) => a - b);
const sum = sorted.reduce((acc, v) => acc + v, 0);
return {
count,
mean: Math.round(sum / count),
median: percentile(sorted, 50),
p25: percentile(sorted, 25),
p75: percentile(sorted, 75),
min: sorted[0],
max: sorted[sorted.length - 1],
buckets,
};
}
async getDropoff(surveyId: string): Promise<
Array<{
questionId: string;
questionText: string;
respondentsReached: number;
respondentsAnswered: number;
dropoffRate: number;
}>
> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const counts = await this.respondents.countBySurveyId(surveyId);
const dropoffData = await this.answers.getDropoffData(surveyId);
const totalRespondents = counts.total;
// Drop-off is measured against the PREVIOUS question's reached cohort, and
// the first question's baseline is the total respondents — so people who
// started but answered nothing register as a drop at question one.
return dropoffData.map((d, i) => {
const reached = d.reached_count;
const previousReached = i === 0 ? totalRespondents : dropoffData[i - 1].reached_count;
const dropoffRate = previousReached > 0 ? Math.round(((previousReached - reached) / previousReached) * 100) : 0;
return {
questionId: d.question_id,
questionText: d.question_text,
respondentsReached: reached,
respondentsAnswered: d.answer_count,
dropoffRate: Math.max(0, dropoffRate),
};
});
}
async listRespondents(
surveyId: string,
offset: number,
limit: number,
): Promise<{
respondents: Array<{ id: string; createdAt: string; completedAt: string | null; answerCount: number }>;
total: number;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
const data = await this.respondents.listBySurveyId(surveyId, offset, limit);
return {
respondents: data.respondents.map((r) => ({
id: r.id,
createdAt: r.created_at,
completedAt: r.completed_at,
answerCount: r.answer_count,
})),
total: data.total,
};
}
async getRespondentDetail(
surveyId: string,
respondentId: string,
): Promise<{
id: string;
createdAt: string;
completedAt: string | null;
answers: Array<{
questionId: string;
questionText: string;
questionType: string;
value: string;
otherText: string | null;
}>;
}> {
const respondent = await this.respondents.getById(respondentId);
if (!respondent || respondent.survey_id !== surveyId) {
throw new ServiceError('Respondent not found', 404);
}
const answers = await this.answers.getAnswersForRespondent(respondentId);
return {
id: respondent.id,
createdAt: respondent.created_at,
completedAt: respondent.completed_at,
answers: answers.map((a) => ({
questionId: a.question_id,
questionText: a.question_text,
questionType: a.question_type,
value: a.answer,
otherText: a.other_text,
})),
};
}
async searchAnswers(
surveyId: string,
query: string,
questionId?: string,
pagination?: { offset: number; limit: number },
): Promise<{
results: Array<{ respondentId: string; questionId: string; questionText: string; answer: string }>;
total: number;
offset: number;
limit: number;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!query || query.trim().length < 2) {
throw new ServiceError('Search query must be at least 2 characters', 400);
}
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 50;
const { results: rawResults, total } = await this.answers.searchTextAnswers(
surveyId,
query.trim(),
questionId,
offset,
limit,
);
return {
results: rawResults.map((r) => ({
respondentId: r.respondent_id,
questionId: r.question_id,
questionText: r.question_text,
answer: r.answer,
})),
total,
offset,
limit,
};
}
async getLiveResults(
surveyId: string,
presenceCounts?: { activeViewers: number; activeRespondents: number },
): Promise<{
respondentCounts: { total: number; completed: number };
results: AggregatedResult[];
liveCounts: { activeViewers: number; activeRespondents: number };
}> {
const baseResults = await this.getResults(surveyId);
const liveCounts = presenceCounts ?? {
activeViewers: 0,
activeRespondents: await this.respondents.countActiveBySurveyId(surveyId),
};
return { ...baseResults, liveCounts };
}
private csvSafe(value: string): string {
// CSV injection (CWE-1236): spreadsheets evaluate cells starting with
// =, +, -, @, tab or CR as formulas, so an answer like `=HYPERLINK(...)`
// exfiltrates adjacent rows when an admin opens the export. The leading
// apostrophe defangs it (OWASP) and is stripped from the rendered cell.
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
return safe.replace(/"/g, '""');
}
private async createNewRespondent(surveyId: string, ipAddress: string): Promise<ResumeResult> {
const id = crypto.randomUUID();
await this.respondents.create({
id,
survey_id: surveyId,
ip_address: ipAddress,
is_complete: 0,
created_at: new Date().toISOString(),
completed_at: null,
});
return {
answers: {},
nextQuestionIndex: 0,
isComplete: false,
respondentId: id,
isNewRespondent: true,
};
}
/**
* Resume at the LAST answered question's index rather than the first
* unanswered one: optional questions a respondent deliberately skipped
* would otherwise send them backwards. Returns 0 when nothing is answered.
*/
private findResumeIndex(
questions: QuestionRow[],
answers: Record<string, { value: string; otherText?: string }>,
): number {
let lastAnsweredIndex = -1;
for (let i = 0; i < questions.length; i++) {
if (answers[questions[i].id]) {
lastAnsweredIndex = i;
}
}
// Resume ON that question, not after it — the answer may be half-finished
// (e.g. mid-typing when the tab crashed).
return Math.max(0, lastAnsweredIndex);
}
}
@@ -0,0 +1,592 @@
import {
SurveyRepository,
SectionRepository,
QuestionRepository,
type SurveyRow,
type SectionRow,
type QuestionRow,
} from '../repositories/survey.repository';
import { ServiceError } from './errors';
import { VALID_QUESTION_TYPES, SLUG_PATTERN } from '../constants';
import { hashPassword } from '../utils/crypto';
export interface SurveyWithDetails {
survey: SurveyRow;
sections: SectionRow[];
questions: QuestionRow[];
}
export interface CreateSurveyInput {
title: string;
description?: string;
}
export interface UpdateSurveyInput {
title?: string;
description?: string;
slug?: string;
welcome_title?: string;
welcome_description?: string;
thank_you_title?: string;
thank_you_description?: string;
closes_at?: string | null;
max_responses?: number | null;
randomize_questions?: boolean;
randomize_options?: boolean;
password?: string | null;
}
export interface CreateSectionInput {
title: string;
description?: string;
}
export interface UpdateSectionInput {
title?: string;
description?: string;
}
export interface CreateQuestionInput {
text: string;
description?: string;
type: string;
options?: Array<{ label: string; value: string }>;
required?: boolean;
has_other?: boolean;
other_prompt?: string;
max_length?: number;
placeholder?: string;
conditional?: { showIf: { questionId: string; condition: string; value?: string; values?: string[] } };
config?: Record<string, unknown>;
}
export interface UpdateQuestionInput {
section_id?: string;
text?: string;
description?: string;
type?: string;
options?: Array<{ label: string; value: string }>;
required?: boolean;
has_other?: boolean;
other_prompt?: string;
max_length?: number;
placeholder?: string;
conditional?: { showIf: { questionId: string; condition: string; value?: string; values?: string[] } } | null;
config?: Record<string, unknown> | null;
}
export interface SurveyDefinition {
version: number;
title: string;
description?: string | null;
welcomeTitle?: string | null;
welcomeDescription?: string | null;
thankYouTitle?: string | null;
thankYouDescription?: string | null;
sections: Array<{
title: string;
description?: string | null;
questions?: Array<{
text: string;
description?: string | null;
type: string;
options?: Array<{ label: string; value: string }> | null;
required?: boolean;
hasOther?: boolean;
otherPrompt?: string | null;
maxLength?: number | null;
placeholder?: string | null;
config?: Record<string, unknown> | null;
}>;
}>;
}
export class SurveyService {
constructor(
private surveys: SurveyRepository,
private sections: SectionRepository,
private questions: QuestionRepository,
) {}
async listSurveys(includeArchived = false): Promise<SurveyRow[]> {
return this.surveys.listAll(includeArchived);
}
async listSurveysPaginated(opts: {
includeArchived?: boolean;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ surveys: SurveyRow[]; total: number }> {
return this.surveys.listPaginated(opts);
}
async getSurvey(id: string): Promise<SurveyWithDetails> {
const survey = await this.surveys.getById(id);
if (!survey) {
throw new ServiceError('Survey not found', 404);
}
const [sections, questions] = await Promise.all([
this.sections.getBySurveyId(id),
this.questions.getBySurveyId(id),
]);
return { survey, sections, questions };
}
async getPublishedSurvey(slug: string): Promise<SurveyWithDetails> {
const survey = await this.surveys.getBySlug(slug);
if (!survey || survey.status !== 'published') {
throw new ServiceError('Survey not found', 404);
}
const [sections, questions] = await Promise.all([
this.sections.getBySurveyId(survey.id),
this.questions.getBySurveyId(survey.id),
]);
return { survey, sections, questions };
}
async createSurvey(input: CreateSurveyInput): Promise<SurveyRow> {
if (!input.title?.trim()) {
throw new ServiceError('Title is required', 400);
}
const now = new Date().toISOString();
const survey: SurveyRow = {
id: crypto.randomUUID(),
title: input.title.trim(),
description: input.description?.trim() ?? null,
slug: null,
status: 'draft',
welcome_title: null,
welcome_description: null,
thank_you_title: null,
thank_you_description: null,
closes_at: null,
max_responses: null,
randomize_questions: 0,
randomize_options: 0,
password_hash: null,
archived_at: null,
created_at: now,
updated_at: now,
};
await this.surveys.create(survey);
return survey;
}
async updateSurvey(id: string, input: UpdateSurveyInput, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
}
const fields: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (input.title !== undefined) {
if (!input.title.trim()) {
throw new ServiceError('Title cannot be empty', 400);
}
fields.title = input.title.trim();
}
if (input.slug !== undefined) {
if (input.slug !== null && input.slug !== '') {
if (!SLUG_PATTERN.test(input.slug)) {
throw new ServiceError('Slug must be 3-50 characters, lowercase alphanumeric and hyphens only', 400);
}
const slugOwner = await this.surveys.getBySlug(input.slug);
if (slugOwner && slugOwner.id !== id) {
throw new ServiceError('Slug is already in use', 409);
}
fields.slug = input.slug;
} else {
fields.slug = 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;
if (input.password !== undefined) {
if (input.password && input.password.length > 0) {
if (input.password.length < 4) {
throw new ServiceError('Survey password must be at least 4 characters', 400);
}
fields.password_hash = await hashPassword(input.password);
} else {
fields.password_hash = null;
}
}
await this.surveys.update(id, fields);
return { ...existing, ...fields } as SurveyRow;
}
async deleteSurvey(id: string): Promise<void> {
const existing = await this.surveys.getById(id);
if (!existing) throw new ServiceError('Survey not found', 404);
await this.surveys.delete(id);
}
async publishSurvey(id: string, details?: SurveyWithDetails): Promise<SurveyRow> {
if (!details) details = await this.getSurvey(id);
const { survey, sections, questions } = details;
if (!survey.slug) {
throw new ServiceError('Survey must have a slug before publishing', 400);
}
if (sections.length === 0) {
throw new ServiceError('Survey must have at least one section', 400);
}
if (questions.length === 0) {
throw new ServiceError('Survey must have at least one question', 400);
}
await this.surveys.update(id, {
status: 'published',
updated_at: new Date().toISOString(),
});
return { ...survey, status: 'published' };
}
async unpublishSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
}
await this.surveys.update(id, {
status: 'draft',
updated_at: new Date().toISOString(),
});
return { ...existing, status: 'draft' };
}
async createSection(surveyId: string, input: CreateSectionInput): Promise<SectionRow> {
if (!input.title?.trim()) {
throw new ServiceError('Section title is required', 400);
}
const maxOrder = await this.sections.getMaxSortOrder(surveyId);
const section: SectionRow = {
id: crypto.randomUUID(),
survey_id: surveyId,
title: input.title.trim(),
description: input.description?.trim() ?? null,
sort_order: maxOrder + 1,
};
await this.sections.create(section);
return section;
}
async updateSection(id: string, input: UpdateSectionInput, existing?: SectionRow): Promise<SectionRow> {
if (!existing) {
existing = (await this.sections.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Section not found', 404);
}
const fields: Record<string, unknown> = {};
if (input.title !== undefined) {
if (!input.title.trim()) {
throw new ServiceError('Section title cannot be empty', 400);
}
fields.title = input.title.trim();
}
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
if (Object.keys(fields).length === 0) return existing;
await this.sections.update(id, fields);
return { ...existing, ...fields } as SectionRow;
}
async deleteSection(id: string): Promise<void> {
await this.questions.deleteBySectionId(id);
await this.sections.delete(id);
}
async reorderSections(_surveyId: string, items: Array<{ id: string; sort_order: number }>): Promise<void> {
await this.sections.reorder(items);
}
async createQuestion(sectionId: string, input: CreateQuestionInput, section?: SectionRow): Promise<QuestionRow> {
if (!section) {
section = (await this.sections.getById(sectionId)) ?? undefined;
if (!section) throw new ServiceError('Section not found', 404);
}
if (!input.text?.trim()) {
throw new ServiceError('Question text is required', 400);
}
if (!VALID_QUESTION_TYPES.includes(input.type)) {
throw new ServiceError(`Invalid question type. Must be one of: ${VALID_QUESTION_TYPES.join(', ')}`, 400);
}
if (['radio', 'checkbox', 'dropdown'].includes(input.type) && (!input.options || input.options.length === 0)) {
throw new ServiceError('Radio, checkbox, and dropdown questions must have options', 400);
}
const maxOrder = await this.questions.getMaxSortOrder(sectionId);
const question: QuestionRow = {
id: crypto.randomUUID(),
survey_id: section.survey_id,
section_id: sectionId,
text: input.text.trim(),
description: input.description?.trim() ?? null,
type: input.type,
options: input.options ? JSON.stringify(input.options) : null,
required: input.required !== false ? 1 : 0,
has_other: input.has_other ? 1 : 0,
other_prompt: input.other_prompt?.trim() ?? null,
max_length: input.max_length ?? null,
placeholder: input.placeholder?.trim() ?? null,
sort_order: maxOrder + 1,
conditional: input.conditional ? JSON.stringify(input.conditional) : null,
config: input.config ? JSON.stringify(input.config) : null,
};
await this.questions.create(question);
return question;
}
async updateQuestion(id: string, input: UpdateQuestionInput, existing?: QuestionRow): Promise<QuestionRow> {
if (!existing) {
existing = (await this.questions.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Question not found', 404);
}
const fields: Record<string, unknown> = {};
if (input.section_id !== undefined) {
const section = await this.sections.getById(input.section_id);
if (!section) {
throw new ServiceError('Target section not found', 404);
}
fields.section_id = input.section_id;
}
if (input.text !== undefined) {
if (!input.text.trim()) {
throw new ServiceError('Question text cannot be empty', 400);
}
fields.text = input.text.trim();
}
if (input.type !== undefined) {
if (!VALID_QUESTION_TYPES.includes(input.type)) {
throw new ServiceError(`Invalid question type. Must be one of: ${VALID_QUESTION_TYPES.join(', ')}`, 400);
}
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.conditional !== undefined) {
fields.conditional = input.conditional ? JSON.stringify(input.conditional) : null;
}
if (input.config !== undefined) {
fields.config = input.config ? JSON.stringify(input.config) : null;
}
if (Object.keys(fields).length === 0) return existing;
await this.questions.update(id, fields);
return { ...existing, ...fields } as QuestionRow;
}
async deleteQuestion(id: string): Promise<void> {
await this.questions.delete(id);
}
async reorderQuestions(_sectionId: string, items: Array<{ id: string; sort_order: number }>): Promise<void> {
await this.questions.reorder(items);
}
async duplicateSurvey(id: string): Promise<SurveyWithDetails> {
const { survey, sections, questions } = await this.getSurvey(id);
const now = new Date().toISOString();
const newSurvey: SurveyRow = {
id: crypto.randomUUID(),
title: `${survey.title} (Copy)`,
description: survey.description,
slug: null,
status: 'draft',
welcome_title: survey.welcome_title,
welcome_description: survey.welcome_description,
thank_you_title: survey.thank_you_title,
thank_you_description: survey.thank_you_description,
closes_at: survey.closes_at,
max_responses: survey.max_responses,
randomize_questions: survey.randomize_questions,
randomize_options: survey.randomize_options,
password_hash: null,
archived_at: null,
created_at: now,
updated_at: now,
};
await this.surveys.create(newSurvey);
const sectionIdMap = new Map<string, string>();
const newSections: SectionRow[] = [];
for (const section of sections) {
const newSectionId = crypto.randomUUID();
sectionIdMap.set(section.id, newSectionId);
const newSection: SectionRow = {
id: newSectionId,
survey_id: newSurvey.id,
title: section.title,
description: section.description,
sort_order: section.sort_order,
};
newSections.push(newSection);
await this.sections.create(newSection);
}
const newQuestions: QuestionRow[] = [];
for (const question of questions) {
const newQuestion: QuestionRow = {
id: crypto.randomUUID(),
survey_id: newSurvey.id,
section_id: sectionIdMap.get(question.section_id) ?? question.section_id,
text: question.text,
description: question.description,
type: question.type,
options: question.options,
required: question.required,
has_other: question.has_other,
other_prompt: question.other_prompt,
max_length: question.max_length,
placeholder: question.placeholder,
sort_order: question.sort_order,
conditional: question.conditional,
config: question.config,
};
newQuestions.push(newQuestion);
await this.questions.create(newQuestion);
}
return { survey: newSurvey, sections: newSections, questions: newQuestions };
}
async archiveSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
}
const now = new Date().toISOString();
await this.surveys.update(id, { archived_at: now, updated_at: now });
return { ...existing, archived_at: now, updated_at: now };
}
async unarchiveSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
}
const now = new Date().toISOString();
await this.surveys.update(id, { archived_at: null, updated_at: now });
return { ...existing, archived_at: null, updated_at: now };
}
async exportDefinition(id: string, details?: SurveyWithDetails): Promise<SurveyDefinition> {
if (!details) details = await this.getSurvey(id);
const { survey, sections, questions } = details;
return {
version: 1,
title: survey.title,
description: survey.description,
welcomeTitle: survey.welcome_title,
welcomeDescription: survey.welcome_description,
thankYouTitle: survey.thank_you_title,
thankYouDescription: survey.thank_you_description,
sections: sections
.sort((a, b) => a.sort_order - b.sort_order)
.map((s) => ({
title: s.title,
description: s.description,
questions: questions
.filter((q) => q.section_id === s.id)
.sort((a, b) => a.sort_order - b.sort_order)
.map((q) => ({
text: q.text,
description: q.description,
type: q.type,
options: q.options ? JSON.parse(q.options) : null,
required: q.required === 1,
hasOther: q.has_other === 1,
otherPrompt: q.other_prompt,
maxLength: q.max_length,
placeholder: q.placeholder,
config: q.config ? JSON.parse(q.config) : null,
})),
})),
};
}
async importDefinition(def: SurveyDefinition): Promise<SurveyWithDetails> {
if (!def.title?.trim()) {
throw new ServiceError('Import must have a title', 400);
}
if (!def.sections?.length) {
throw new ServiceError('Import must have at least one section', 400);
}
const survey = await this.createSurvey({ title: def.title, description: def.description ?? undefined });
if (def.welcomeTitle || def.welcomeDescription || def.thankYouTitle || def.thankYouDescription) {
await this.updateSurvey(survey.id, {
welcome_title: def.welcomeTitle ?? undefined,
welcome_description: def.welcomeDescription ?? undefined,
thank_you_title: def.thankYouTitle ?? undefined,
thank_you_description: def.thankYouDescription ?? undefined,
});
}
for (const sDef of def.sections) {
const section = await this.createSection(survey.id, {
title: sDef.title,
description: sDef.description ?? undefined,
});
for (const qDef of sDef.questions ?? []) {
await this.createQuestion(section.id, {
text: qDef.text,
description: qDef.description ?? undefined,
type: qDef.type,
options: qDef.options ?? undefined,
required: qDef.required,
has_other: qDef.hasOther,
other_prompt: qDef.otherPrompt ?? undefined,
max_length: qDef.maxLength ?? undefined,
placeholder: qDef.placeholder ?? undefined,
config: qDef.config ?? undefined,
});
}
}
return this.getSurvey(survey.id);
}
}
@@ -0,0 +1,49 @@
import { TagRepository, type TagRow } from '../repositories/tag.repository';
import { ServiceError } from './errors';
export class TagService {
constructor(private tags: TagRepository) {}
async listTags(): Promise<TagRow[]> {
return this.tags.listAll();
}
async createTag(input: { name: string; color?: string }): Promise<TagRow> {
if (!input.name?.trim()) throw new ServiceError('Tag name is required', 400);
const tag: TagRow = {
id: crypto.randomUUID(),
name: input.name.trim(),
color: input.color ?? null,
created_at: new Date().toISOString(),
};
await this.tags.create(tag);
return tag;
}
async updateTag(id: string, input: { name?: string; color?: string | null }): Promise<TagRow> {
const existing = await this.tags.getById(id);
if (!existing) throw new ServiceError('Tag not found', 404);
const fields: Partial<Omit<TagRow, 'id' | 'created_at'>> = {};
if (input.name !== undefined) {
if (!input.name.trim()) throw new ServiceError('Tag name cannot be empty', 400);
fields.name = input.name.trim();
}
if (input.color !== undefined) fields.color = input.color;
await this.tags.update(id, fields);
return { ...existing, ...fields };
}
async deleteTag(id: string): Promise<void> {
const existing = await this.tags.getById(id);
if (!existing) throw new ServiceError('Tag not found', 404);
await this.tags.delete(id);
}
async getTagsForSurvey(surveyId: string): Promise<TagRow[]> {
return this.tags.getTagsForSurvey(surveyId);
}
async setTagsForSurvey(surveyId: string, tagIds: string[]): Promise<void> {
await this.tags.setTagsForSurvey(surveyId, tagIds);
}
}
@@ -0,0 +1,3 @@
import type { AutoRouterType, IRequest } from 'itty-router';
export type AppRouter = AutoRouterType<IRequest, [Env, ExecutionContext]>;
@@ -0,0 +1,68 @@
import { PBKDF2_ITERATIONS } from '../constants';
export function constantTimeEqual(a: string, b: string): boolean {
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);
}
return result === 0;
}
export async function hashPassword(password: string): Promise<string> {
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(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
key,
256,
);
const saltB64 = btoa(String.fromCharCode(...salt));
const hashB64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
return `${saltB64}:${hashB64}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [saltB64, hashB64] = stored.split(':');
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(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
key,
256,
);
const computedB64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
return constantTimeEqual(computedB64, hashB64);
}
export async function signToken(data: string, secret: string): Promise<string> {
if (!secret) throw new Error('Signing secret is not configured');
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
export async function verifyToken(data: string, token: string, secret: string): Promise<boolean> {
const expected = await signToken(data, secret);
return constantTimeEqual(expected, token);
}
export function passwordFingerprint(passwordHash: string | null | undefined): string {
if (!passwordHash) return 'none';
let h1 = 0x811c9dc5;
let h2 = 0x01000193;
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;
}
return h1.toString(36) + h2.toString(36);
}
@@ -0,0 +1,6 @@
export function toClientSurvey<T extends { password_hash?: string | null }>(
survey: T,
): Omit<T, 'password_hash'> & { has_password: boolean } {
const { password_hash, ...rest } = survey;
return { ...rest, has_password: !!password_hash };
}
@@ -0,0 +1,51 @@
/**
* Session-token helpers: standalone from AuthService so contexts without a
* `db` handle (DO routing path, tests) can validate a session cookie using
* only the HMAC secret. AuthService.validateSessionToken delegates here so
* the two verification paths can't drift.
*/
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));
}
export async function verifySessionToken(token: string, sessionSecret: string): Promise<UserInfo | null> {
try {
if (!sessionSecret) return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(sessionSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const valid = await crypto.subtle.verify(
'HMAC',
key,
b64urlToBytes(parts[2]),
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
);
if (!valid) return null;
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>;
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) return null;
if (typeof payload.sub !== 'string') return null;
return {
sub: payload.sub,
email: (payload.email as string) ?? '',
name: (payload.name as string) ?? '',
role: (payload.role as UserInfo['role']) ?? 'viewer',
};
} catch {
return null;
}
}
@@ -0,0 +1,64 @@
import { PASSWORD_SESSION_MAX_AGE } from '../constants';
import { passwordFingerprint, signToken, verifyToken } from './crypto';
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function signedPayload(surveyId: string, exp: number, fp: string): string {
return `${surveyId}.${exp}.${fp}`;
}
export async function mintSurveyPasswordToken(
surveyId: string,
passwordHash: string | null | undefined,
secret: string,
): Promise<string> {
const exp = nowSeconds() + PASSWORD_SESSION_MAX_AGE;
const fp = passwordFingerprint(passwordHash);
const sig = await signToken(signedPayload(surveyId, exp, fp), secret);
return `${exp}.${fp}.${sig}`;
}
interface ParsedToken {
exp: number;
fp: string;
sig: string;
}
function parse(token: string): ParsedToken | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
const exp = Number(parts[0]);
if (!Number.isInteger(exp) || !parts[1] || !parts[2]) return null;
return { exp, fp: parts[1], sig: parts[2] };
}
export async function verifySurveyPasswordTokenSignature(
token: string,
surveyId: string,
secret: string,
): Promise<{ valid: boolean; fingerprint?: string }> {
const parsed = parse(token);
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 };
}
export async function verifySurveyPasswordToken(
token: string,
surveyId: string,
passwordHash: string | null | undefined,
secret: string,
): Promise<boolean> {
const { valid, fingerprint } = await verifySurveyPasswordTokenSignature(token, surveyId, secret);
return valid && fingerprint === passwordFingerprint(passwordHash);
}
export function fingerprintMatchesPassword(
fingerprint: string | null | undefined,
passwordHash: string | null | undefined,
): boolean {
return !!fingerprint && fingerprint === passwordFingerprint(passwordHash);
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { request, getAdminCookie, ADMIN_PASSWORD } from './helpers';
describe('Authentication', () => {
it('POST /api/auth/password-login succeeds with correct password', async () => {
await getAdminCookie();
const res = await request('/api/auth/password-login', {
method: 'POST',
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toContain('survey_session=');
});
it('POST /api/auth/password-login fails with wrong password', async () => {
const res = await request('/api/auth/password-login', {
method: 'POST',
body: JSON.stringify({ password: 'wrong-password' }),
});
expect(res.status).toBe(401);
});
it('POST /api/auth/password-login fails with empty password', async () => {
const res = await request('/api/auth/password-login', {
method: 'POST',
body: JSON.stringify({ password: '' }),
});
expect(res.status).toBe(400);
});
it('GET /api/auth/me with valid cookie returns authenticated user', async () => {
const cookie = await getAdminCookie();
const res = await request('/api/auth/me', { cookie });
const data = (await res.json()) as { authenticated: boolean; user?: { role: string; email: string } };
expect(data.authenticated).toBe(true);
expect(data.user?.role).toBe('admin');
});
it('GET /api/auth/me without cookie returns unauthenticated', async () => {
const res = await request('/api/auth/me');
const data = (await res.json()) as { authenticated: boolean };
// Might be needsSetup or just unauthenticated depending on state
expect(data.authenticated).toBe(false);
});
it('POST /api/auth/logout clears session cookie', async () => {
const res = await request('/api/auth/logout', { method: 'POST' });
expect(res.status).toBe(204);
expect(res.headers.get('set-cookie')).toContain('Max-Age=0');
});
it('admin endpoints return 401 without auth', async () => {
const res = await request('/api/surveys');
expect(res.status).toBe(401);
});
it('admin endpoints work with valid auth', async () => {
const cookie = await getAdminCookie();
const res = await request('/api/surveys', { cookie });
expect(res.status).toBe(200);
});
it('POST /api/auth/setup fails when already set up', async () => {
await getAdminCookie(); // ensure setup
const res = await request('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ password: 'another-password-123' }),
});
expect(res.status).toBe(400);
});
it('invalid session cookie returns 401', async () => {
const res = await request('/api/surveys', {
cookie: 'survey_session=invalid.token.here',
});
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,204 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { request, createCookieForRole } from './helpers';
describe('Authorization - unauthenticated requests', () => {
it('rejects unauthenticated GET /api/surveys with 401', async () => {
const res = await request('/api/surveys');
expect(res.status).toBe(401);
});
it('rejects unauthenticated POST /api/surveys with 401', async () => {
const res = await request('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Should Fail' }),
});
expect(res.status).toBe(401);
});
it('rejects unauthenticated GET /api/tags with 401', async () => {
const res = await request('/api/tags');
expect(res.status).toBe(401);
});
it('rejects unauthenticated POST /api/tags with 401', async () => {
const res = await request('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: 'Should Fail' }),
});
expect(res.status).toBe(401);
});
});
describe('Authorization - public routes', () => {
it('allows unauthenticated GET /api/auth/me', async () => {
const res = await request('/api/auth/me');
expect(res.status).toBe(200);
});
it('allows unauthenticated GET /api/s/:slug (returns 404 for missing slug, not 401)', async () => {
const res = await request('/api/s/nonexistent-slug');
expect(res.status).not.toBe(401);
});
});
describe('Authorization - viewer role', () => {
let viewerCookie: string;
beforeAll(async () => {
viewerCookie = await createCookieForRole('viewer');
});
it('can GET /api/surveys', async () => {
const res = await request('/api/surveys', { cookie: viewerCookie });
expect(res.status).toBe(200);
});
it('can GET /api/tags', async () => {
const res = await request('/api/tags', { cookie: viewerCookie });
expect(res.status).toBe(200);
});
it('cannot POST /api/surveys (403)', async () => {
const res = await request('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Viewer Survey' }),
cookie: viewerCookie,
});
expect(res.status).toBe(403);
});
it('cannot POST /api/tags (403)', async () => {
const res = await request('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: 'Viewer Tag' }),
cookie: viewerCookie,
});
expect(res.status).toBe(403);
});
});
describe('Authorization - editor role', () => {
let editorCookie: string;
let surveyId: string;
let tagId: string;
beforeAll(async () => {
editorCookie = await createCookieForRole('editor');
const surveyRes = await request('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: `Editor Auth Test ${Date.now()}` }),
cookie: editorCookie,
});
const survey = (await surveyRes.json()) as { id: string };
surveyId = survey.id;
const tagRes = await request('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `Editor Auth Tag ${Date.now()}` }),
cookie: editorCookie,
});
const tag = (await tagRes.json()) as { id: string };
tagId = tag.id;
});
it('can GET /api/surveys', async () => {
const res = await request('/api/surveys', { cookie: editorCookie });
expect(res.status).toBe(200);
});
it('can POST /api/surveys (201)', async () => {
const res = await request('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: `Editor Create ${Date.now()}` }),
cookie: editorCookie,
});
expect(res.status).toBe(201);
});
it('can POST /api/tags (201)', async () => {
const res = await request('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `Editor Tag ${Date.now()}` }),
cookie: editorCookie,
});
expect(res.status).toBe(201);
});
it('cannot DELETE /api/surveys/:id (403)', async () => {
const res = await request(`/api/surveys/${surveyId}`, {
method: 'DELETE',
cookie: editorCookie,
});
expect(res.status).toBe(403);
});
it('cannot DELETE /api/tags/:id (403)', async () => {
const res = await request(`/api/tags/${tagId}`, {
method: 'DELETE',
cookie: editorCookie,
});
expect(res.status).toBe(403);
});
});
describe('Authorization - admin role', () => {
let adminCookie: string;
beforeAll(async () => {
adminCookie = await createCookieForRole('admin');
});
it('can GET /api/surveys', async () => {
const res = await request('/api/surveys', { cookie: adminCookie });
expect(res.status).toBe(200);
});
it('can POST /api/surveys (201)', async () => {
const res = await request('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: `Admin Create ${Date.now()}` }),
cookie: adminCookie,
});
expect(res.status).toBe(201);
});
it('can DELETE /api/surveys/:id', async () => {
const createRes = await request('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: `Admin Delete ${Date.now()}` }),
cookie: adminCookie,
});
const { id } = (await createRes.json()) as { id: string };
const res = await request(`/api/surveys/${id}`, {
method: 'DELETE',
cookie: adminCookie,
});
expect(res.status).toBe(204);
});
it('can POST /api/tags (201)', async () => {
const res = await request('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `Admin Tag ${Date.now()}` }),
cookie: adminCookie,
});
expect(res.status).toBe(201);
});
it('can DELETE /api/tags/:id', async () => {
const createRes = await request('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `Admin Delete Tag ${Date.now()}` }),
cookie: adminCookie,
});
const { id } = (await createRes.json()) as { id: string };
const res = await request(`/api/tags/${id}`, {
method: 'DELETE',
cookie: adminCookie,
});
expect(res.status).toBe(204);
});
});
@@ -0,0 +1,158 @@
const API = process.env.API_URL || 'http://localhost:8787';
// Admin setup is one-time per server. In CI the integration suite and the
// Playwright e2e suite run sequentially against the SAME wrangler dev
// instance, so whichever runs first performs setup and the other must be able
// to log in with the same password. Keep this default in sync with the e2e
// helper (e2e/helpers.ts TEST_PASSWORD); override both via TEST_PASSWORD.
const ADMIN_PASSWORD = process.env.TEST_PASSWORD || 'e2e-test-password-12345';
let adminCookie: string | null = null;
export async function request(path: string, options?: RequestInit & { cookie?: string }): Promise<Response> {
const { cookie, ...fetchOptions } = options ?? {};
const headers: Record<string, string> = {};
if (fetchOptions.body) {
headers['Content-Type'] = 'application/json';
}
if (cookie) {
headers['Cookie'] = cookie;
}
if (fetchOptions.headers) {
Object.assign(headers, fetchOptions.headers);
}
return fetch(`${API}${path}`, { ...fetchOptions, headers });
}
export async function authedRequest(path: string, options?: RequestInit): Promise<Response> {
const cookie = await getAdminCookie();
return request(path, { ...options, cookie });
}
export async function getAdminCookie(): Promise<string> {
if (adminCookie) return adminCookie;
const meRes = await request('/api/auth/me');
const me = (await meRes.json()) as { needsSetup?: boolean };
if (me.needsSetup) {
const setupRes = await request('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
const setCookie = setupRes.headers.get('set-cookie');
if (setCookie) {
adminCookie = setCookie.split(';')[0];
return adminCookie;
}
}
const loginRes = await request('/api/auth/password-login', {
method: 'POST',
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
const setCookie = loginRes.headers.get('set-cookie');
if (setCookie) {
adminCookie = setCookie.split(';')[0];
}
if (!adminCookie) throw new Error('Failed to authenticate for integration tests');
return adminCookie;
}
export async function createPublishedSurvey(options?: {
title?: string;
slug?: string;
password?: string;
}): Promise<{ surveyId: string; slug: string; sectionId: string; questionIds: string[] }> {
const title = options?.title ?? `Test Survey ${Date.now()}`;
const slug = options?.slug ?? `test-${Date.now()}`;
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title }),
});
const survey = (await surveyRes.json()) as { id: string };
await authedRequest(`/api/surveys/${survey.id}`, {
method: 'PUT',
body: JSON.stringify({
slug,
...(options?.password ? { password: options.password } : {}),
}),
});
const sectionRes = await authedRequest(`/api/surveys/${survey.id}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'Section 1' }),
});
const section = (await sectionRes.json()) as { id: string };
const questionIds: string[] = [];
for (const q of [
{
text: 'Pick one',
type: 'radio',
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
],
},
{ text: 'Your name', type: 'text' },
{ text: 'Rate us', type: 'nps' },
]) {
const qRes = await authedRequest(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
method: 'POST',
body: JSON.stringify(q),
});
const question = (await qRes.json()) as { id: string };
questionIds.push(question.id);
}
await authedRequest(`/api/surveys/${survey.id}/publish`, { method: 'PUT' });
return { surveyId: survey.id, slug, sectionId: section.id, questionIds };
}
const DEV_SESSION_SECRET = 'dev-session-secret-DO-NOT-USE-IN-PRODUCTION';
const SESSION_COOKIE_NAME = 'survey_session';
// Fail fast if the integration suite is ever pointed at a non-dev server —
// an env-supplied SESSION_SECRET here would silently mint invalid cookies
// and every auth-gated test would 401 with no hint why. The dev server is
// the only environment this forgery should ever succeed against.
if (process.env.SESSION_SECRET && process.env.SESSION_SECRET !== DEV_SESSION_SECRET) {
throw new Error(
'Integration tests must run against the dev wrangler server (SESSION_SECRET=dev-session-secret-DO-NOT-USE-IN-PRODUCTION).',
);
}
export async function createCookieForRole(role: 'admin' | 'editor' | 'viewer'): Promise<string> {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = btoa(
JSON.stringify({
sub: `test-${role}`,
email: `${role}@test.local`,
name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`,
role,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 8 * 60 * 60,
}),
);
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(DEV_SESSION_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
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, '_')
.replace(/=+$/, '');
return `${SESSION_COOKIE_NAME}=${header}.${payload}.${signature}`;
}
export { ADMIN_PASSWORD };
@@ -0,0 +1,114 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { request, getAdminCookie, createPublishedSurvey } from './helpers';
describe('Respondent Flow', () => {
let slug: string;
let questionIds: string[];
beforeAll(async () => {
await getAdminCookie();
const survey = await createPublishedSurvey({ slug: `respondent-test-${Date.now()}` });
slug = survey.slug;
questionIds = survey.questionIds;
});
it('gets published survey', async () => {
const res = await request(`/api/s/${slug}`);
expect(res.status).toBe(200);
const data = (await res.json()) as { survey: { title: string }; sections: unknown[]; questions: unknown[] };
expect(data.survey.title).toBeTruthy();
expect(data.questions.length).toBe(3);
});
it('resumes and creates new respondent', async () => {
const res = await request(`/api/s/${slug}/resume`);
expect(res.status).toBe(200);
const data = (await res.json()) as { isComplete: boolean; answers: Record<string, unknown> };
expect(data.isComplete).toBe(false);
expect(res.headers.get('set-cookie')).toBeTruthy();
});
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 batchRes = await request(`/api/s/${slug}/answers/batch`, {
method: 'POST',
cookie: ridCookie,
body: JSON.stringify({
answers: [
{ questionId: questionIds[0], value: 'A' },
{ questionId: questionIds[1], value: 'Test User' },
{ questionId: questionIds[2], value: '9' },
],
}),
});
expect(batchRes.status).toBe(204);
const completeRes = await request(`/api/s/${slug}/complete`, {
method: 'POST',
cookie: ridCookie,
});
expect(completeRes.status).toBe(204);
const resumeAgain = await request(`/api/s/${slug}/resume`, { cookie: ridCookie });
const data = (await resumeAgain.json()) as { isComplete: boolean };
expect(data.isComplete).toBe(true);
});
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 res = await request(`/api/s/${slug}/answers/batch`, {
method: 'POST',
cookie: ridCookie,
body: JSON.stringify({
answers: [{ questionId: 'fake-question-id', value: 'test' }],
}),
});
expect(res.status).toBe(400);
});
});
describe('Survey Password Protection', () => {
let slug: string;
beforeAll(async () => {
await getAdminCookie();
const survey = await createPublishedSurvey({
slug: `password-test-${Date.now()}`,
password: 'survey-pass-1234',
});
slug = survey.slug;
});
it('password-protected survey requires auth', async () => {
const res = await request(`/api/s/${slug}`);
expect(res.status).toBe(200);
const data = (await res.json()) as { requiresPassword?: boolean; questions: unknown[] };
expect(data.requiresPassword).toBe(true);
expect(data.questions).toEqual([]);
});
it('wrong password returns 403', async () => {
const res = await request(`/api/s/${slug}/auth`, {
method: 'POST',
body: JSON.stringify({ password: 'wrong' }),
});
expect(res.status).toBe(403);
});
it('correct password grants access', async () => {
const authRes = await request(`/api/s/${slug}/auth`, {
method: 'POST',
body: JSON.stringify({ password: 'survey-pass-1234' }),
});
expect(authRes.status).toBe(204);
const pwCookie = authRes.headers.get('set-cookie')?.split(';')[0] ?? '';
const surveyRes = await request(`/api/s/${slug}`, { cookie: pwCookie });
const data = (await surveyRes.json()) as { questions: unknown[] };
expect(data.questions.length).toBe(3);
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { request, authedRequest, getAdminCookie, createPublishedSurvey } from './helpers';
describe('Results API', () => {
let surveyId: string;
let slug: string;
let questionIds: string[];
beforeAll(async () => {
await getAdminCookie();
const survey = await createPublishedSurvey({ slug: `results-test-${Date.now()}` });
surveyId = survey.surveyId;
slug = survey.slug;
questionIds = survey.questionIds;
const resumeRes = await request(`/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
await request(`/api/s/${slug}/answers/batch`, {
method: 'POST',
cookie: ridCookie,
body: JSON.stringify({
answers: [
{ questionId: questionIds[0], value: 'A' },
{ questionId: questionIds[1], value: 'Test Name' },
{ questionId: questionIds[2], value: '8' },
],
}),
});
await request(`/api/s/${slug}/complete`, { method: 'POST', cookie: ridCookie });
});
it('gets aggregated results', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results`);
expect(res.status).toBe(200);
const data = (await res.json()) as { respondentCounts: { total: number; completed: number }; results: unknown[] };
expect(data.respondentCounts.completed).toBe(1);
expect(data.results.length).toBeGreaterThan(0);
});
it('gets live results', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/live`);
expect(res.status).toBe(200);
const data = (await res.json()) as { liveCounts: { activeViewers: number; activeRespondents: number } };
expect(data.liveCounts).toBeDefined();
});
it('gets timeline data', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/timeline?granularity=day`);
expect(res.status).toBe(200);
const data = (await res.json()) as Array<{ period: string; started: number; completed: number }>;
expect(data.length).toBeGreaterThan(0);
expect(data[0].started).toBeGreaterThanOrEqual(data[0].completed);
});
it('gets dropoff data', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/dropoff`);
expect(res.status).toBe(200);
const data = (await res.json()) as Array<{ questionId: string; dropoffRate: number }>;
expect(data.length).toBe(3);
});
it('lists respondents with pagination', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/respondents?offset=0&limit=10`);
expect(res.status).toBe(200);
const data = (await res.json()) as { respondents: Array<{ id: string }>; total: number };
expect(data.total).toBe(1);
expect(data.respondents.length).toBe(1);
});
it('gets respondent detail', async () => {
const listRes = await authedRequest(`/api/surveys/${surveyId}/results/respondents`);
const { respondents } = (await listRes.json()) as { respondents: Array<{ id: string }> };
const res = await authedRequest(`/api/surveys/${surveyId}/results/respondents/${respondents[0].id}`);
expect(res.status).toBe(200);
const data = (await res.json()) as { answers: Array<{ questionId: string; value: string }> };
expect(data.answers.length).toBe(3);
});
it('searches text answers', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/search?q=Test`);
expect(res.status).toBe(200);
const data = (await res.json()) as { results: Array<{ answer: string }>; total: number };
expect(data.results.length).toBeGreaterThan(0);
expect(data.results[0].answer).toContain('Test');
});
it('deletes a respondent', async () => {
const listRes = await authedRequest(`/api/surveys/${surveyId}/results/respondents`);
const { respondents } = (await listRes.json()) as { respondents: Array<{ id: string }> };
const res = await authedRequest(`/api/surveys/${surveyId}/results/respondents/${respondents[0].id}`, {
method: 'DELETE',
});
expect(res.status).toBe(204);
const afterRes = await authedRequest(`/api/surveys/${surveyId}/results/respondents`);
const after = (await afterRes.json()) as { total: number };
expect(after.total).toBe(0);
});
it('exports CSV', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/export?format=csv`);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/csv');
});
it('exports JSON', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/results/export?format=json`);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
});
});
@@ -0,0 +1,204 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { authedRequest, createPublishedSurvey, getAdminCookie } from './helpers';
describe('Survey CRUD', () => {
beforeAll(async () => {
await getAdminCookie();
});
it('creates a survey', async () => {
const res = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Test Survey' }),
});
expect(res.status).toBe(201);
const data = (await res.json()) as { id: string; title: string; status: string };
expect(data.title).toBe('Test Survey');
expect(data.status).toBe('draft');
expect(data.id).toBeTruthy();
});
it('lists surveys', async () => {
const res = await authedRequest('/api/surveys');
expect(res.status).toBe(200);
const data = (await res.json()) as { surveys: Array<{ id: string }>; total: number };
expect(data.surveys.length).toBeGreaterThan(0);
});
it('gets survey by ID', async () => {
const createRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Get By ID Test' }),
});
const { id } = (await createRes.json()) as { id: string };
const res = await authedRequest(`/api/surveys/${id}`);
expect(res.status).toBe(200);
const data = (await res.json()) as { survey: { title: string }; sections: unknown[]; questions: unknown[] };
expect(data.survey.title).toBe('Get By ID Test');
expect(data.sections).toEqual([]);
expect(data.questions).toEqual([]);
});
it('updates a survey', async () => {
const createRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Before Update' }),
});
const { id } = (await createRes.json()) as { id: string };
const res = await authedRequest(`/api/surveys/${id}`, {
method: 'PUT',
body: JSON.stringify({ title: 'After Update', slug: `test-update-${Date.now()}` }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { title: string; slug: string };
expect(data.title).toBe('After Update');
expect(data.slug).toContain('test-update');
});
it('deletes a survey', async () => {
const createRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'To Delete' }),
});
const { id } = (await createRes.json()) as { id: string };
const res = await authedRequest(`/api/surveys/${id}`, { method: 'DELETE' });
expect(res.status).toBe(204);
const getRes = await authedRequest(`/api/surveys/${id}`);
expect(getRes.status).toBe(404);
});
it('publishes and unpublishes a survey', async () => {
const { surveyId } = await createPublishedSurvey();
// Unpublish
const unpubRes = await authedRequest(`/api/surveys/${surveyId}/unpublish`, { method: 'PUT' });
expect(unpubRes.status).toBe(200);
const data = (await unpubRes.json()) as { status: string };
expect(data.status).toBe('draft');
});
it('archives and unarchives a survey', async () => {
const createRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Archive Test' }),
});
const { id } = (await createRes.json()) as { id: string };
const archiveRes = await authedRequest(`/api/surveys/${id}/archive`, { method: 'PUT' });
expect(archiveRes.status).toBe(200);
const archived = (await archiveRes.json()) as { archived_at: string | null };
expect(archived.archived_at).toBeTruthy();
const unarchiveRes = await authedRequest(`/api/surveys/${id}/unarchive`, { method: 'PUT' });
expect(unarchiveRes.status).toBe(200);
const unarchived = (await unarchiveRes.json()) as { archived_at: string | null };
expect(unarchived.archived_at).toBeNull();
});
it('duplicates a survey', async () => {
const { surveyId } = await createPublishedSurvey();
const res = await authedRequest(`/api/surveys/${surveyId}/duplicate`, { method: 'POST' });
expect(res.status).toBe(201);
const data = (await res.json()) as {
survey: { id: string; title: string };
sections: unknown[];
questions: unknown[];
};
expect(data.survey.id).not.toBe(surveyId);
expect(data.survey.title).toContain('(Copy)');
expect(data.sections.length).toBeGreaterThan(0);
expect(data.questions.length).toBeGreaterThan(0);
});
it('exports and imports a survey definition', async () => {
const { surveyId } = await createPublishedSurvey();
// Export
const exportRes = await authedRequest(`/api/surveys/${surveyId}/definition`);
expect(exportRes.status).toBe(200);
const definition = (await exportRes.json()) as { version: number; title: string; sections: unknown[] };
expect(definition.version).toBe(1);
expect(definition.sections.length).toBeGreaterThan(0);
// Import
const importRes = await authedRequest('/api/surveys/import', {
method: 'POST',
body: JSON.stringify(definition),
});
expect(importRes.status).toBe(201);
const imported = (await importRes.json()) as { survey: { id: string } };
expect(imported.survey.id).not.toBe(surveyId);
});
});
describe('Sections and Questions', () => {
beforeAll(async () => {
await getAdminCookie();
});
it('creates sections and questions', async () => {
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Sections Test' }),
});
const { id: surveyId } = (await surveyRes.json()) as { id: string };
const sectionRes = await authedRequest(`/api/surveys/${surveyId}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'Section A' }),
});
expect(sectionRes.status).toBe(201);
const section = (await sectionRes.json()) as { id: string };
const questionRes = await authedRequest(`/api/surveys/${surveyId}/sections/${section.id}/questions`, {
method: 'POST',
body: JSON.stringify({
text: 'Q1',
type: 'radio',
options: [
{ label: 'Yes', value: 'Yes' },
{ label: 'No', value: 'No' },
],
}),
});
expect(questionRes.status).toBe(201);
});
it('reorders sections', async () => {
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Reorder Test' }),
});
const { id: surveyId } = (await surveyRes.json()) as { id: string };
const s1 = (await (
await authedRequest(`/api/surveys/${surveyId}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'First' }),
})
).json()) as { id: string };
const s2 = (await (
await authedRequest(`/api/surveys/${surveyId}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'Second' }),
})
).json()) as { id: string };
const res = await authedRequest(`/api/surveys/${surveyId}/sections/reorder`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ id: s2.id, sort_order: 0 },
{ id: s1.id, sort_order: 1 },
],
}),
});
expect(res.status).toBe(204);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { authedRequest, getAdminCookie, createPublishedSurvey } from './helpers';
describe('Tags', () => {
beforeAll(async () => {
await getAdminCookie();
});
it('creates a tag', async () => {
const res = await authedRequest('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `Test Tag ${Date.now()}`, color: '#ff0000' }),
});
expect(res.status).toBe(201);
const data = (await res.json()) as { id: string; name: string; color: string };
expect(data.name).toContain('Test Tag');
expect(data.color).toBe('#ff0000');
});
it('lists tags', async () => {
const res = await authedRequest('/api/tags');
expect(res.status).toBe(200);
const data = (await res.json()) as Array<{ name: string }>;
expect(data.length).toBeGreaterThan(0);
});
it('updates a tag', async () => {
const createRes = await authedRequest('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `To Update ${Date.now()}` }),
});
const { id } = (await createRes.json()) as { id: string };
const res = await authedRequest(`/api/tags/${id}`, {
method: 'PUT',
body: JSON.stringify({ name: `Updated Tag ${Date.now()}` }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { name: string };
expect(data.name).toContain('Updated Tag');
});
it('deletes a tag', async () => {
const createRes = await authedRequest('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `To Delete ${Date.now()}` }),
});
const { id } = (await createRes.json()) as { id: string };
const res = await authedRequest(`/api/tags/${id}`, { method: 'DELETE' });
expect(res.status).toBe(204);
});
it('assigns and retrieves tags for a survey', async () => {
const { surveyId } = await createPublishedSurvey();
const tagRes = await authedRequest('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: `Survey Tag ${Date.now()}` }),
});
const tag = (await tagRes.json()) as { id: string };
// Assign
const assignRes = await authedRequest(`/api/surveys/${surveyId}/tags`, {
method: 'PUT',
body: JSON.stringify({ tagIds: [tag.id] }),
});
expect(assignRes.status).toBe(204);
// Retrieve
const getRes = await authedRequest(`/api/surveys/${surveyId}/tags`);
expect(getRes.status).toBe(200);
const tags = (await getRes.json()) as Array<{ id: string }>;
expect(tags.length).toBe(1);
expect(tags[0].id).toBe(tag.id);
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { authedRequest, createPublishedSurvey, getAdminCookie } from './helpers';
describe('Survey creation validation', () => {
beforeAll(async () => {
await getAdminCookie();
});
it('rejects empty title', async () => {
const res = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: '' }),
});
expect(res.status).toBe(400);
});
it('rejects whitespace-only title', async () => {
const res = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: ' ' }),
});
expect(res.status).toBe(400);
});
});
describe('Survey publish validation', () => {
beforeAll(async () => {
await getAdminCookie();
});
it('rejects publishing without a slug', async () => {
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'No Slug Survey' }),
});
const { id } = (await surveyRes.json()) as { id: string };
// Add a section with a question so slug is the only missing requirement
const sectionRes = await authedRequest(`/api/surveys/${id}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'Section' }),
});
const section = (await sectionRes.json()) as { id: string };
await authedRequest(`/api/surveys/${id}/sections/${section.id}/questions`, {
method: 'POST',
body: JSON.stringify({ text: 'Q1', type: 'text' }),
});
const res = await authedRequest(`/api/surveys/${id}/publish`, { method: 'PUT' });
expect(res.status).toBe(400);
});
it('rejects publishing with no sections', async () => {
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'No Sections Survey' }),
});
const { id } = (await surveyRes.json()) as { id: string };
await authedRequest(`/api/surveys/${id}`, {
method: 'PUT',
body: JSON.stringify({ slug: `no-sections-${Date.now()}` }),
});
const res = await authedRequest(`/api/surveys/${id}/publish`, { method: 'PUT' });
expect(res.status).toBe(400);
});
it('rejects publishing when section has no questions', async () => {
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'No Questions Survey' }),
});
const { id } = (await surveyRes.json()) as { id: string };
await authedRequest(`/api/surveys/${id}`, {
method: 'PUT',
body: JSON.stringify({ slug: `no-questions-${Date.now()}` }),
});
await authedRequest(`/api/surveys/${id}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'Empty Section' }),
});
const res = await authedRequest(`/api/surveys/${id}/publish`, { method: 'PUT' });
expect(res.status).toBe(400);
});
});
describe('Question creation validation', () => {
let surveyId: string;
let sectionId: string;
beforeAll(async () => {
await getAdminCookie();
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Question Validation Survey' }),
});
const { id } = (await surveyRes.json()) as { id: string };
surveyId = id;
const sectionRes = await authedRequest(`/api/surveys/${id}/sections`, {
method: 'POST',
body: JSON.stringify({ title: 'Section' }),
});
const section = (await sectionRes.json()) as { id: string };
sectionId = section.id;
});
it('rejects empty question text', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/sections/${sectionId}/questions`, {
method: 'POST',
body: JSON.stringify({ text: '', type: 'text' }),
});
expect(res.status).toBe(400);
});
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' }),
});
expect(res.status).toBe(400);
});
});
describe('Duplicate slug validation', () => {
beforeAll(async () => {
await getAdminCookie();
});
it('rejects duplicate slug', async () => {
const slug = `dup-slug-${Date.now()}`;
// Create and publish a survey with the slug
await createPublishedSurvey({ slug });
// Create a second survey and try to use the same slug
const surveyRes = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: 'Duplicate Slug Survey' }),
});
const { id } = (await surveyRes.json()) as { id: string };
const res = await authedRequest(`/api/surveys/${id}`, {
method: 'PUT',
body: JSON.stringify({ slug }),
});
expect(res.status).toBe(409);
});
});
@@ -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", "@cloudflare/workers-types"]
},
"include": ["worker-configuration.d.ts", "src/**/*.ts", "../shared/**/*.ts"]
}
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.integration.ts'],
testTimeout: 15000,
hookTimeout: 15000,
sequence: {
concurrent: false,
},
fileParallelism: false,
},
});
@@ -0,0 +1,15 @@
interface Env {
DB: D1Database;
SURVEY_SESSIONS: DurableObjectNamespace;
PASSWORD_SECRET: string;
OIDC_ISSUER: string;
OIDC_CLIENT_ID: string;
OIDC_CLIENT_SECRET: string;
OIDC_REDIRECT_URI: string;
OIDC_ROLE_CLAIM: string;
OIDC_ROLE_MAP_ADMIN: string;
OIDC_ROLE_MAP_EDITOR: string;
SESSION_SECRET: string;
DISABLE_PASSWORD_AUTH?: string;
ADMIN_SETUP_TOKEN?: string;
}
@@ -0,0 +1,42 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "survey-api-dev",
"main": "src/dev-entry.ts",
"compatibility_date": "2025-06-03",
"d1_databases": [
{
"binding": "DB",
"database_name": "survey",
"database_id": "local",
},
],
"durable_objects": {
"bindings": [
{
"name": "SURVEY_SESSIONS",
"class_name": "SurveyDO",
},
],
},
// SurveyDO is the only Durable Object class and is SQLite-backed from the
// start. (The earlier v1 "SurveySession" class never existed — it only
// produced a "no such Durable Object class is exported" warning on boot.)
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["SurveyDO"],
},
],
// DEV ONLY — override with `wrangler secret put` in production
"vars": {
"PASSWORD_SECRET": "dev-password-secret-DO-NOT-USE-IN-PRODUCTION",
"OIDC_ISSUER": "http://localhost:9090",
"OIDC_CLIENT_ID": "survey-app",
"OIDC_CLIENT_SECRET": "test-client-secret",
"OIDC_REDIRECT_URI": "http://localhost:5173/api/auth/callback",
"OIDC_ROLE_CLAIM": "groups",
"OIDC_ROLE_MAP_ADMIN": "survey-admin",
"OIDC_ROLE_MAP_EDITOR": "survey-editor",
"SESSION_SECRET": "dev-session-secret-DO-NOT-USE-IN-PRODUCTION",
},
}
@@ -0,0 +1,30 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "survey-sessions",
"main": "src/durable-objects/index.ts",
"compatibility_date": "2025-06-03",
"durable_objects": {
"bindings": [
{
"name": "SURVEY_SESSIONS",
"class_name": "SurveyDO",
},
],
},
// The deployed sessions worker reached tag "v2" through this chain, so it must
// be preserved as-is: v1 created the original "SurveySession" class and v2
// introduced the SQLite-backed "SurveyDO". The Terraform deploy (worker.tf)
// asserts old_tag == new_tag == "v2" against this state. `durable-objects/
// index.ts` still exports `SurveyDO as SurveySession` so the v1 class name
// remains resolvable.
"migrations": [
{
"tag": "v1",
"new_classes": ["SurveySession"],
},
{
"tag": "v2",
"new_sqlite_classes": ["SurveyDO"],
},
],
}
@@ -0,0 +1,30 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "survey-api",
"main": "src/index.ts",
"compatibility_date": "2025-06-03",
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "survey",
"database_id": "local"
}
],
"durable_objects": {
"bindings": [
{
"name": "SURVEY_SESSIONS",
"class_name": "SurveyDO",
"script_name": "survey-sessions"
}
]
}
// NOTE: Production `vars` are supplied via Terraform (see
// deployment/modules/cloudflare/workers/survey/worker.tf) and/or
// `wrangler secret put`. Keep this file free of `vars` so a local
// `wrangler deploy` never accidentally pushes dev credentials to
// prod. For local dev, use `wrangler-dev.jsonc`.
}
+50
View File
@@ -0,0 +1,50 @@
services:
survey:
build: .
ports:
- '${PORT:-3000}:3000'
environment:
- DATABASE_URL=${DATABASE_URL:-/data/survey.db}
# Required — fail fast at `docker compose up` if unset rather than booting
# with empty secrets (which mint/verify invalid session & password tokens).
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET is required}
- PASSWORD_SECRET=${PASSWORD_SECRET:?PASSWORD_SECRET is required}
- COOKIE_SECURE=${COOKIE_SECURE:-false}
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_ROLE_CLAIM=${OIDC_ROLE_CLAIM:-groups}
- OIDC_ROLE_MAP_ADMIN=${OIDC_ROLE_MAP_ADMIN:-survey-admin}
- OIDC_ROLE_MAP_EDITOR=${OIDC_ROLE_MAP_EDITOR:-survey-editor}
volumes:
- survey-data:/data
restart: unless-stopped
# node:20-slim ships neither wget nor curl, so probe /api/auth/me (200 when
# healthy, authenticated or not) with Node's built-in fetch.
healthcheck:
test:
- CMD
- node
- '-e'
- "fetch('http://localhost:3000/api/auth/me').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
# Optional: Use PostgreSQL instead of SQLite
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: survey
POSTGRES_USER: survey
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-survey}
volumes:
- pgdata:/var/lib/postgresql/data
profiles:
- postgres
volumes:
survey-data:
pgdata:
+246
View File
@@ -0,0 +1,246 @@
import { test, expect } from '@playwright/test';
import { startOidcServer, ISSUER } from './oidc-server';
import { API, TEST_PASSWORD } from './helpers';
const BASE = process.env.BASE_URL || 'http://localhost:5173';
// Serial because setup must precede login.
test.describe.serial('Password auth', () => {
let needsSetup = false;
test.beforeAll(async () => {
const res = await fetch(`${API}/api/auth/me`);
const data = (await res.json()) as { needsSetup?: boolean };
needsSetup = !!data.needsSetup;
});
test('first visit shows setup screen', async ({ page }) => {
test.skip(!needsSetup, 'Admin already set up — requires fresh database');
await page.goto('/');
await expect(page.getByText('Welcome to FUTO Surveys')).toBeVisible();
await expect(page.getByText('Set up your admin password')).toBeVisible();
});
test('short password is rejected', async ({ page }) => {
test.skip(!needsSetup, 'Admin already set up — requires fresh database');
await page.goto('/');
await expect(page.getByText('Welcome to FUTO Surveys')).toBeVisible();
await page.getByPlaceholder('At least 8 characters').fill('short');
await page.getByPlaceholder('Confirm password').fill('short');
await page.getByRole('button', { name: 'Create Admin Account' }).click();
await expect(page.getByText('Password must be at least 8 characters')).toBeVisible();
});
test('mismatched passwords are rejected', async ({ page }) => {
test.skip(!needsSetup, 'Admin already set up — requires fresh database');
await page.goto('/');
await expect(page.getByText('Welcome to FUTO Surveys')).toBeVisible();
await page.getByPlaceholder('At least 8 characters').fill('validpassword1');
await page.getByPlaceholder('Confirm password').fill('differentpassword');
await page.getByRole('button', { name: 'Create Admin Account' }).click();
await expect(page.getByText('Passwords do not match')).toBeVisible();
});
test('can create admin password and auto-login', async ({ page }) => {
test.skip(!needsSetup, 'Admin already set up — requires fresh database');
await page.goto('/');
await expect(page.getByText('Welcome to FUTO Surveys')).toBeVisible();
await page.getByPlaceholder('At least 8 characters').fill(TEST_PASSWORD);
await page.getByPlaceholder('Confirm password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Create Admin Account' }).click();
await expect(page.getByText('FUTO Surveys')).toBeVisible();
await expect(page.getByText('admin', { exact: false })).toBeVisible();
});
test('after setup, new context shows login screen', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await expect(page.getByText('Sign in to FUTO Surveys')).toBeVisible();
await expect(page.getByPlaceholder('Admin password')).toBeVisible();
await context.close();
});
test('wrong password shows error', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await expect(page.getByText('Sign in to FUTO Surveys')).toBeVisible();
await page.getByPlaceholder('Admin password').fill('wrong-password');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByText('Invalid password')).toBeVisible();
await context.close();
});
test('correct password logs in and shows dashboard', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await expect(page.getByText('Sign in to FUTO Surveys')).toBeVisible();
await page.getByPlaceholder('Admin password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.locator('header').getByText('FUTO Surveys')).toBeVisible({ timeout: 5000 });
await context.close();
});
test('logout clears session and shows login', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await expect(page.getByText('Sign in to FUTO Surveys')).toBeVisible();
await page.getByPlaceholder('Admin password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByText('FUTO Surveys')).toBeVisible();
await page.getByTitle('Log out').click();
await expect(page.getByText('Sign in to FUTO Surveys')).toBeVisible();
await context.close();
});
test('protected page redirects to login when not authenticated', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${BASE}/create`);
await expect(page.getByText('Sign in to FUTO Surveys')).toBeVisible();
await context.close();
});
});
test.describe.serial('OIDC auth', () => {
let oidcServer: Awaited<ReturnType<typeof startOidcServer>> | null = null;
let oidcAvailable = false;
test.beforeAll(async () => {
try {
const meRes = await fetch(`${API}/api/auth/me`);
const me = (await meRes.json()) as { oidcEnabled?: boolean };
if (!me.oidcEnabled) {
console.log('OIDC is not enabled on the backend — skipping OIDC tests');
return;
}
} catch {
console.log('Backend not reachable — skipping OIDC tests');
return;
}
try {
oidcServer = await startOidcServer();
const disco = await fetch(`${ISSUER}/.well-known/openid-configuration`);
if (disco.ok) {
oidcAvailable = true;
}
} catch (err) {
console.log('Failed to start OIDC server:', err);
}
});
test.afterAll(async () => {
if (oidcServer) {
await oidcServer.stop();
}
});
test('login screen shows SSO button when OIDC is configured', async ({ browser }) => {
test.skip(!oidcAvailable, 'OIDC server not available');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await expect(page.getByRole('button', { name: 'Sign in with SSO' })).toBeVisible();
await context.close();
});
test('clicking SSO redirects to OIDC provider', async ({ browser }) => {
test.skip(!oidcAvailable, 'OIDC server not available');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await page.getByRole('button', { name: 'Sign in with SSO' }).click();
await page.waitForURL(/localhost:9090/);
expect(page.url()).toContain('localhost:9090');
await context.close();
});
test('OIDC login flow authenticates and redirects back', async ({ browser }) => {
test.skip(!oidcAvailable, 'OIDC server not available');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await page.getByRole('button', { name: 'Sign in with SSO' }).click();
await page.waitForURL(/localhost:9090\/interaction\//);
await page.locator('input[name="login"]').fill('admin@test.com');
await page.locator('input[name="password"]').fill('testpassword');
await page.locator('button[type="submit"]').click();
try {
await page.locator('button:has-text("Authorize")').waitFor({ timeout: 3000 });
await page.locator('button:has-text("Authorize")').click();
} catch {
// No consent screen — that's fine
}
await page.waitForURL(/localhost:(5173|8787|3000)/, { timeout: 10_000 });
await expect(page.locator('header').getByText('FUTO Surveys')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Test Admin')).toBeVisible({ timeout: 5000 });
await context.close();
});
test('OIDC user has correct role from claims', async ({ browser }) => {
test.skip(!oidcAvailable, 'OIDC server not available');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE);
await page.getByRole('button', { name: 'Sign in with SSO' }).click();
await page.waitForURL(/localhost:9090\/interaction\//);
await page.locator('input[name="login"]').fill('editor@test.com');
await page.locator('input[name="password"]').fill('testpassword');
await page.locator('button[type="submit"]').click();
try {
await page.locator('button:has-text("Authorize")').waitFor({ timeout: 3000 });
await page.locator('button:has-text("Authorize")').click();
} catch {
// No consent screen
}
await page.waitForURL(/localhost:(5173|8787|3000)/, { timeout: 10_000 });
await expect(page.getByText('Test Editor')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('editor', { exact: true })).toBeVisible();
await context.close();
});
});
+114
View File
@@ -0,0 +1,114 @@
const API = process.env.API_URL || 'http://localhost:8787';
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'e2e-test-password-12345';
let sessionCookie: string | null = null;
export async function ensureAuth(): Promise<string> {
if (sessionCookie) return sessionCookie;
const meRes = await fetch(`${API}/api/auth/me`);
const me = (await meRes.json()) as { authenticated: boolean; needsSetup?: boolean };
if (me.needsSetup) {
const setupRes = await fetch(`${API}/api/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: TEST_PASSWORD }),
});
const cookie = setupRes.headers.get('set-cookie');
if (cookie) {
sessionCookie = cookie.split(';')[0];
return sessionCookie;
}
}
const loginRes = await fetch(`${API}/api/auth/password-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: TEST_PASSWORD }),
});
const cookie = loginRes.headers.get('set-cookie');
if (cookie) {
sessionCookie = cookie.split(';')[0];
}
if (!sessionCookie) {
throw new Error('Failed to authenticate for E2E tests');
}
return sessionCookie;
}
export function getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (sessionCookie) {
headers['Cookie'] = sessionCookie;
}
return headers;
}
export async function apiPost(path: string, body?: unknown, retries = 3) {
await ensureAuth();
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: getAuthHeaders(),
...(body ? { body: JSON.stringify(body) } : {}),
});
if (res.status === 503 && attempt < retries - 1) {
// Retry on 503 (transient DO initialization failure)
await new Promise((r) => setTimeout(r, 1000));
continue;
}
if (!res.ok && res.status !== 201) {
const text = await res.text();
throw new Error(`POST ${path} failed (${res.status}): ${text}`);
}
if (res.status === 204) return undefined;
return res.json();
}
}
export async function apiPut(path: string, body?: unknown) {
await ensureAuth();
const res = await fetch(`${API}${path}`, {
method: 'PUT',
headers: getAuthHeaders(),
...(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;
return res.json();
}
export async function apiGet(path: string) {
await ensureAuth();
const res = await fetch(`${API}${path}`, {
headers: getAuthHeaders(),
});
return res.json();
}
export async function apiRawGet(path: string) {
await ensureAuth();
return fetch(`${API}${path}`, {
headers: getAuthHeaders(),
});
}
export async function apiDelete(path: string) {
await ensureAuth();
return fetch(`${API}${path}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
}
export function parseCookie(cookie: string): { name: string; value: string } {
const idx = cookie.indexOf('=');
return { name: cookie.slice(0, idx), value: cookie.slice(idx + 1) };
}
export { API, TEST_PASSWORD };
+269
View File
@@ -0,0 +1,269 @@
import Provider, { type AccountClaims, type FindAccount, type Configuration } from 'oidc-provider';
const ISSUER = 'http://localhost:9090';
const CLIENT_ID = 'survey-app';
const CLIENT_SECRET = 'test-client-secret';
interface TestUser {
password: string;
claims: AccountClaims & { groups: string[] };
}
const TEST_USERS: Record<string, TestUser> = {
'admin@test.com': {
password: 'testpassword',
claims: {
sub: 'test-admin-1',
email: 'admin@test.com',
email_verified: true,
name: 'Test Admin',
groups: ['survey-admin'],
},
},
'editor@test.com': {
password: 'testpassword',
claims: {
sub: 'test-editor-1',
email: 'editor@test.com',
email_verified: true,
name: 'Test Editor',
groups: ['survey-editor'],
},
},
'viewer@test.com': {
password: 'testpassword',
claims: {
sub: 'test-viewer-1',
email: 'viewer@test.com',
email_verified: true,
name: 'Test Viewer',
groups: [],
},
},
};
const findAccount: FindAccount = async (_ctx, id) => {
const entry = Object.entries(TEST_USERS).find(([, u]) => u.claims.sub === id);
if (!entry) return undefined;
const [, user] = entry;
return {
accountId: user.claims.sub,
async claims() {
return user.claims;
},
};
};
function authenticateUser(login: string, password: string) {
const user = TEST_USERS[login];
if (!user || user.password !== password) return undefined;
return { accountId: user.claims.sub };
}
const configuration: Configuration = {
clients: [
{
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uris: [
'http://localhost:8787/api/auth/callback',
'http://localhost:5173/api/auth/callback',
'http://localhost:3000/api/auth/callback',
'http://localhost:4444/api/auth/callback',
],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
},
],
findAccount,
claims: {
openid: ['sub'],
email: ['email', 'email_verified'],
profile: ['name', 'groups'],
},
scopes: ['openid', 'email', 'profile'],
features: {
devInteractions: { enabled: false },
},
conformIdTokenClaims: true,
pkce: {
required: () => false,
},
interactions: {
url(_ctx, interaction) {
return `/interaction/${interaction.uid}`;
},
},
cookies: {
keys: ['oidc-test-secret-key-1'],
},
// Let oidc-provider generate keys at startup (avoids Node.js v24 crypto compat issues with static keys)
};
export async function startOidcServer(): Promise<{
server: ReturnType<Provider['listen']>;
stop: () => Promise<void>;
}> {
const provider = new Provider(ISSUER, configuration);
// Middleware added with provider.use() runs before oidc-provider's own routes.
provider.use(async (ctx, next) => {
const url = ctx.URL;
const interactionGetMatch = url.pathname.match(/^\/interaction\/([^/]+)$/);
if (interactionGetMatch && ctx.method === 'GET') {
const uid = interactionGetMatch[1];
try {
const details = await provider.interactionDetails(ctx.req, ctx.res);
if (details.prompt.name === 'login') {
ctx.type = 'text/html';
ctx.body = `<!DOCTYPE html>
<html><body>
<form method="post" action="/interaction/${uid}/login">
<input name="login" placeholder="Email" type="text" />
<input name="password" placeholder="Password" type="password" />
<button type="submit">Sign in</button>
</form>
</body></html>`;
return;
}
if (details.prompt.name === 'consent') {
ctx.type = 'text/html';
ctx.body = `<!DOCTYPE html>
<html><body>
<form method="post" action="/interaction/${uid}/confirm">
<p>Authorize this application?</p>
<button type="submit">Authorize</button>
</form>
</body></html>`;
return;
}
} catch {
ctx.status = 500;
ctx.body = 'Interaction error';
return;
}
}
if (ctx.method === 'POST' && url.pathname.match(/^\/interaction\/[^/]+\/login$/)) {
const uid = url.pathname.split('/')[2];
const body = await readBody(ctx.req);
const params = new URLSearchParams(body);
const login = params.get('login') ?? '';
const password = params.get('password') ?? '';
const account = authenticateUser(login, password);
if (!account) {
ctx.type = 'text/html';
ctx.body = `<!DOCTYPE html>
<html><body>
<p class="error">Invalid credentials</p>
<form method="post" action="/interaction/${uid}/login">
<input name="login" placeholder="Email" type="text" />
<input name="password" placeholder="Password" type="password" />
<button type="submit">Sign in</button>
</form>
</body></html>`;
return;
}
const result = { login: { accountId: account.accountId } };
await provider.interactionFinished(ctx.req, ctx.res, result, {
mergeWithLastSubmission: false,
});
// interactionFinished writes the response directly — prevent koa from overwriting
ctx.respond = false;
return;
}
if (ctx.method === 'POST' && url.pathname.match(/^\/interaction\/[^/]+\/confirm$/)) {
try {
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
const {
prompt: { details: promptDetails },
params,
session,
} = interactionDetails;
const accountId = session?.accountId;
if (!accountId) {
ctx.status = 400;
ctx.body = 'No session';
return;
}
let grant = interactionDetails.grantId
? await provider.Grant.find(interactionDetails.grantId)
: new provider.Grant({ accountId, clientId: params.client_id as string });
if (!grant) {
grant = new provider.Grant({ accountId, clientId: params.client_id as string });
}
const missingOIDCScope = (promptDetails.missingOIDCScope as string[] | undefined) ?? [];
if (missingOIDCScope.length > 0) {
grant.addOIDCScope(missingOIDCScope.join(' '));
}
const missingOIDCClaims = (promptDetails.missingOIDCClaims as string[] | undefined) ?? [];
if (missingOIDCClaims.length > 0) {
grant.addOIDCClaims(missingOIDCClaims);
}
const missingResourceScopes =
(promptDetails.missingResourceScopes as Record<string, string[]> | undefined) ?? {};
for (const [indicator, scopes] of Object.entries(missingResourceScopes)) {
grant.addResourceScope(indicator, scopes.join(' '));
}
const grantId = await grant.save();
const result = { consent: { grantId } };
await provider.interactionFinished(ctx.req, ctx.res, result, {
mergeWithLastSubmission: true,
});
ctx.respond = false;
} catch {
ctx.status = 500;
ctx.body = 'Consent error';
}
return;
}
await next();
});
provider.on('server_error', (ctx: unknown, err: Error) => {
console.error('[OIDC server_error]', err.message, err.stack);
});
provider.on('grant.error', (ctx: unknown, err: Error) => {
console.error('[OIDC grant.error]', err.message);
});
const server = provider.listen(9090);
console.log('OIDC test server running on http://localhost:9090');
return {
server,
stop: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
function readBody(req: import('node:http').IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
// Allow running standalone: `npx tsx e2e/oidc-server.ts`
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) {
startOidcServer().then(() => console.log('OIDC server started, press Ctrl+C to stop'));
}
export { ISSUER, CLIENT_ID, CLIENT_SECRET, TEST_USERS };
@@ -0,0 +1,505 @@
import { test, expect, type Page } from '@playwright/test';
import { apiPost, apiPut, apiGet, apiRawGet, API, ensureAuth, parseCookie } from './helpers';
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function waitForTransition(page: Page) {
await page.waitForTimeout(400);
}
async function dismissSectionHeader(page: Page) {
await page.getByRole('button', { name: 'Continue' }).click();
}
interface SurveySetup {
surveyId: string;
slug: string;
sectionId: string;
questionIds: string[];
}
async function createSimpleSurvey(opts?: {
title?: string;
slug?: string;
questionCount?: number;
questionType?: string;
required?: boolean;
publish?: boolean;
options?: Array<{ label: string; value: string }>;
closesAt?: string;
maxResponses?: number;
}): Promise<SurveySetup> {
const title = opts?.title ?? 'E2E Phase2 Test';
const slug = opts?.slug ?? `e2e-p2-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const questionCount = opts?.questionCount ?? 1;
const questionType = opts?.questionType ?? 'radio';
const required = opts?.required ?? true;
const publish = opts?.publish ?? false;
const survey = await apiPost('/api/surveys', { title });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
const questionIds: string[] = [];
for (let i = 0; i < questionCount; i++) {
const body: Record<string, unknown> = {
text: `Question ${i + 1}`,
type: questionType,
required,
};
if (['radio', 'checkbox', 'dropdown'].includes(questionType)) {
body.options = opts?.options ?? [
{ label: 'Alpha', value: 'Alpha' },
{ label: 'Beta', value: 'Beta' },
{ label: 'Gamma', value: 'Gamma' },
];
}
const q = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, body);
questionIds.push(q.id);
}
await apiPut(`/api/surveys/${survey.id}`, {
slug,
...(opts?.closesAt ? { closes_at: opts.closesAt } : {}),
...(opts?.maxResponses !== undefined ? { max_responses: opts.maxResponses } : {}),
});
if (publish) {
await apiPut(`/api/surveys/${survey.id}/publish`);
}
return { surveyId: survey.id, slug, sectionId: section.id, questionIds };
}
test.describe('Create page with survey templates', () => {
test('template cards are visible and clicking one pre-populates the builder', async ({ page }) => {
await page.goto('/create');
await expect(page.getByText('Customer Satisfaction')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Event Feedback')).toBeVisible();
await expect(page.getByText('Employee Engagement')).toBeVisible();
await expect(page.getByText('Blank Survey')).toBeVisible();
});
test('clicking a template shows builder with pre-populated title and saves correctly', async ({ page }) => {
await page.goto('/create');
await expect(page.getByText('Customer Satisfaction')).toBeVisible({ timeout: 5000 });
await page.getByText('Customer Satisfaction').first().click();
await waitForTransition(page);
await expect(page.getByText('Create Survey')).toBeVisible({ timeout: 5000 });
const titleInput = page.locator('input[placeholder="Survey title..."]');
await expect(titleInput).toHaveValue('Customer Satisfaction');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10000 });
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
const url = page.url();
const surveyId = url.match(/\/edit\/([a-f0-9-]+)/)?.[1];
expect(surveyId).toBeTruthy();
const data = await apiGet(`/api/surveys/${surveyId}`);
expect(data.sections.length).toBeGreaterThanOrEqual(2);
expect(data.questions.length).toBeGreaterThanOrEqual(3);
});
});
test.describe('Create page saves sections (blank survey)', () => {
test('creates a blank survey and adds sections on edit page', async ({ page }) => {
await page.goto('/create');
await expect(page.getByText('Blank Survey')).toBeVisible({ timeout: 5000 });
await page.getByText('Blank Survey').click();
await waitForTransition(page);
await page.locator('input[placeholder="Survey title..."]').fill('Blank Builder E2E');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10000 });
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByText('Add section').click();
await waitForTransition(page);
await page.getByPlaceholder('e.g., About You').fill('Demo Section');
await page.getByRole('button', { name: 'Single Choice' }).last().click();
await waitForTransition(page);
await page.getByPlaceholder('What would you like to ask?').fill('Favorite fruit?');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Changes saved')).toBeVisible({ timeout: 5000 });
const surveyId = page.url().match(/\/edit\/([a-f0-9-]+)/)?.[1];
expect(surveyId).toBeTruthy();
const data = await apiGet(`/api/surveys/${surveyId}`);
expect(data.survey.title).toBe('Blank Builder E2E');
expect(data.sections.length).toBe(1);
expect(data.questions.length).toBe(1);
expect(data.questions[0].text).toBe('Favorite fruit?');
});
});
test.describe('Preview mode', () => {
let setup: SurveySetup;
test.beforeAll(async () => {
setup = await createSimpleSurvey({ title: 'Preview Test Survey' });
});
test('preview modal opens and shows survey content', async ({ page }) => {
await page.goto(`/edit/${setup.surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: 'Preview' }).click();
await waitForTransition(page);
const previewModal = page.locator('.fixed.inset-0.z-50');
await expect(previewModal).toBeVisible({ timeout: 3000 });
const getStartedBtn = previewModal.getByRole('button', { name: 'Get Started' });
await expect(getStartedBtn).toBeVisible({ timeout: 3000 });
await getStartedBtn.click();
await waitForTransition(page);
const continueBtn = previewModal.getByRole('button', { name: 'Continue' });
await continueBtn.click();
await waitForTransition(page);
await expect(previewModal.getByText('Question 1')).toBeVisible({ timeout: 3000 });
await page.keyboard.press('Escape');
await waitForTransition(page);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 3000 });
});
});
test.describe('Inline validation errors', () => {
let setup: SurveySetup;
test.beforeAll(async () => {
setup = await createSimpleSurvey({
title: 'Validation Test',
required: true,
publish: true,
});
});
test('shows "This question is required" when submitting without answer', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await waitForTransition(page);
await dismissSectionHeader(page);
await waitForTransition(page);
await expect(page.getByRole('heading', { name: 'Question 1' })).toBeVisible({ timeout: 3000 });
await page
.getByRole('button', { name: /Next|Submit/ })
.first()
.click();
await waitForTransition(page);
await expect(page.getByRole('alert').getByText('This question is required')).toBeVisible({ timeout: 3000 });
});
});
test.describe('Keyboard navigation', () => {
let setup: SurveySetup;
test.beforeAll(async () => {
setup = await createSimpleSurvey({
title: 'Keyboard Nav Test',
questionType: 'radio',
questionCount: 2,
publish: true,
});
});
test('ArrowDown selects radio option via keyboard', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await waitForTransition(page);
await dismissSectionHeader(page);
await waitForTransition(page);
await expect(page.getByRole('heading', { name: 'Question 1' })).toBeVisible({ timeout: 3000 });
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
await waitForTransition(page);
// Verify Alpha is selected — radio auto-advances to Question 2
await expect(page.getByRole('heading', { name: 'Question 2' })).toBeVisible({ timeout: 5000 });
});
});
test.describe('Survey scheduling and limits', () => {
test('closed survey blocks responses via API', async () => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const setup = await createSimpleSurvey({
title: 'Closed Survey Test',
closesAt: oneDayAgo,
publish: true,
});
const res = await apiRawGet(`/api/s/${setup.slug}/resume`);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain('closed');
});
test('max_responses blocks after limit is reached', async () => {
const setup = await createSimpleSurvey({
title: 'Max Responses Test',
maxResponses: 1,
publish: true,
});
const resume1Res = await apiRawGet(`/api/s/${setup.slug}/resume`);
expect(resume1Res.status).toBe(200);
const cookies1 = resume1Res.headers.get('set-cookie') ?? '';
const ridMatch1 = cookies1.match(/rid_[^=]+=([^;]+)/);
const rid1 = ridMatch1?.[1];
expect(rid1).toBeTruthy();
await fetch(`${API}/api/s/${setup.slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: `rid_${setup.slug}=${rid1}` },
body: JSON.stringify({ answers: [{ questionId: setup.questionIds[0], value: 'Alpha' }] }),
});
await fetch(`${API}/api/s/${setup.slug}/complete`, {
method: 'POST',
headers: { Cookie: `rid_${setup.slug}=${rid1}` },
});
const resume2Res = await apiRawGet(`/api/s/${setup.slug}/resume`);
expect(resume2Res.status).toBe(403);
const body2 = await resume2Res.json();
expect(body2.error).toContain('maximum');
});
});
test.describe('Question reordering via builder', () => {
let setup: SurveySetup;
test.beforeAll(async () => {
setup = await createSimpleSurvey({
title: 'Reorder Test',
questionCount: 3,
});
});
test('move up/down buttons reorder questions and save persists order', async ({ page }) => {
await page.goto(`/edit/${setup.surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('3 questions').first()).toBeVisible({ timeout: 3000 });
await page.getByText('Question 3').click();
await waitForTransition(page);
const expandedQuestion = page.locator('[data-question-index="0-2"]');
const moveUpButton = expandedQuestion.locator('button[title="Move up"]');
await moveUpButton.click();
await waitForTransition(page);
await page.getByRole('button', { name: 'Save' }).click();
await waitForTransition(page);
await expect(page.getByText('Changes saved')).toBeVisible({ timeout: 5000 });
const data = await apiGet(`/api/surveys/${setup.surveyId}`);
const questions = data.questions.sort(
(a: Record<string, number>, b: Record<string, number>) => a.sort_order - b.sort_order,
);
// Question 3 should now have sort_order 1 (moved up from 2)
const q3 = questions.find((q: Record<string, string>) => q.text === 'Question 3');
expect(q3.sort_order).toBe(1);
});
});
test.describe('Question templates in builder', () => {
let surveyId: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Template Q Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Template Section' });
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Placeholder Q',
type: 'text',
});
surveyId = survey.id;
});
test('template dropdown shows categories and adds a template question', async ({ page }) => {
await page.goto(`/edit/${surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: 'Template' }).click();
await waitForTransition(page);
// Category headers need exact match so they don't match other text on the page.
const dropdown = page.locator('.absolute.top-full');
await expect(dropdown).toBeVisible({ timeout: 3000 });
await expect(dropdown.getByText('Feedback', { exact: true })).toBeVisible();
await expect(dropdown.getByText('Demographics', { exact: true })).toBeVisible();
await dropdown.getByRole('button', { name: 'Net Promoter Score' }).click();
await waitForTransition(page);
await expect(page.getByText('How likely are you to recommend us to a friend or colleague?')).toBeVisible({
timeout: 3000,
});
});
});
test.describe('Bulk option paste', () => {
let surveyId: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Bulk Paste Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'S1' });
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Paste Q',
type: 'radio',
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
],
});
surveyId = survey.id;
});
test('paste options modal replaces options', async ({ page }) => {
await page.goto(`/edit/${surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByText('Paste Q').click();
await waitForTransition(page);
await page.getByRole('button', { name: 'Paste options' }).click();
await waitForTransition(page);
await expect(page.getByRole('heading', { name: 'Paste Options' })).toBeVisible({ timeout: 3000 });
const modal = page.locator('.fixed.inset-0.z-50');
const textarea = modal.locator('textarea');
await textarea.fill('Red\nGreen\nBlue\nYellow');
await expect(modal.getByText('4 options')).toBeVisible({ timeout: 3000 });
await modal.getByRole('button', { name: 'Apply' }).click();
await waitForTransition(page);
// OptionListEditor renders one input per option.
const optionInputs = page.locator('.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');
await expect(optionInputs.nth(2)).toHaveValue('Blue');
await expect(optionInputs.nth(3)).toHaveValue('Yellow');
});
});
test.describe('Undo/redo', () => {
let surveyId: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Undo Redo Test' });
surveyId = survey.id;
});
test('Ctrl+Z undoes title change', async ({ page }) => {
await page.goto(`/edit/${surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
const titleInput = page.locator('input[placeholder="Survey title..."]');
await expect(titleInput).toHaveValue('Undo Redo Test');
// Wait for initial snapshot to be taken (debounce timer is 500ms)
await page.waitForTimeout(800);
await titleInput.fill('Changed Title');
await expect(titleInput).toHaveValue('Changed Title');
// Wait for the changed snapshot to be recorded
await page.waitForTimeout(800);
// Press Ctrl+Z (Cmd+Z on Mac) twice — the undo stack contains
// [initial state, changed state]. First undo pops the changed state
// (no-op since it matches current), second undo pops the initial state.
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
await page.keyboard.press(`${modifier}+z`);
await page.waitForTimeout(200);
await page.keyboard.press(`${modifier}+z`);
await waitForTransition(page);
await expect(titleInput).toHaveValue('Undo Redo Test', { timeout: 3000 });
});
});
test.describe('Skip logic builder UI', () => {
test('skip logic dropdown shows preceding questions from the same section', async ({ page }) => {
const setup = await createSimpleSurvey({
title: 'Skip Logic Builder Test',
questionCount: 3,
});
await page.goto(`/edit/${setup.surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Question 3/ }).click();
await waitForTransition(page);
await page.getByText('Skip Logic').click();
await waitForTransition(page);
await expect(page.getByText('No preceding questions')).not.toBeVisible();
await expect(page.getByText('Source question')).toBeVisible({ timeout: 3000 });
const sourceLabel = page.getByText('Source question');
const select = sourceLabel.locator('..').locator('select');
await expect(select).toBeVisible();
await expect(select.locator('option', { hasText: 'Q1:' })).toBeAttached();
await expect(select.locator('option', { hasText: 'Q2:' })).toBeAttached();
});
test('first question shows no preceding questions message', async ({ page }) => {
const setup = await createSimpleSurvey({
title: 'Skip Logic First Q Test',
questionCount: 2,
});
await page.goto(`/edit/${setup.surveyId}`);
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Question 1/ }).click();
await waitForTransition(page);
await page.getByText('Skip Logic').click();
await waitForTransition(page);
await expect(page.getByText('No preceding questions')).toBeVisible({ timeout: 3000 });
});
});
@@ -0,0 +1,151 @@
import { test, expect, type Page } from '@playwright/test';
import { apiPost, apiPut, ensureAuth, parseCookie } from './helpers';
interface SetupResult {
surveyId: string;
slug: string;
sectionId: string;
questionIds: {
q1: string;
q2: string;
q3: string;
q4: string;
};
}
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function waitForTransition(page: Page) {
await page.waitForTimeout(400);
}
async function dismissSectionHeader(page: Page) {
await page.getByRole('button', { name: 'Continue' }).click();
}
async function createConditionalSurvey(): Promise<SetupResult> {
const slug = `e2e-cond-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const survey = await apiPost('/api/surveys', { title: 'Conditional Logic Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
const q1 = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Do you like testing?',
type: 'radio',
required: true,
options: [
{ label: 'Yes', value: 'Yes' },
{ label: 'No', value: 'No' },
],
});
const q2 = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'What do you like about testing?',
type: 'text',
required: true,
placeholder: 'Tell us more',
conditional: {
showIf: {
questionId: q1.id,
condition: 'equals',
value: 'Yes',
},
},
});
const q3 = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'What would make testing better?',
type: 'text',
required: true,
placeholder: 'Your suggestions',
conditional: {
showIf: {
questionId: q1.id,
condition: 'equals',
value: 'No',
},
},
});
const q4 = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Any final thoughts?',
type: 'text',
required: false,
placeholder: 'Optional feedback',
});
await apiPut(`/api/surveys/${survey.id}`, { slug });
await apiPut(`/api/surveys/${survey.id}/publish`);
return {
surveyId: survey.id,
slug,
sectionId: section.id,
questionIds: { q1: q1.id, q2: q2.id, q3: q3.id, q4: q4.id },
};
}
test.describe.serial('Conditional skip logic', () => {
let setup: SetupResult;
test.beforeAll(async () => {
setup = await createConditionalSurvey();
});
test('choosing "Yes" shows Q2, skips Q3, shows Q4', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await waitForTransition(page);
await expect(page.getByText('Do you like testing?')).toBeVisible({ timeout: 3000 });
await page.getByRole('radio', { name: 'Yes' }).click();
// Radio auto-advances, so no Next click is needed here.
await expect(page.getByText('What do you like about testing?')).toBeVisible({ timeout: 3000 });
await page.getByPlaceholder('Tell us more').fill('Everything!');
await waitForTransition(page);
await page
.getByRole('button', { name: /Next|Submit/ })
.first()
.click();
await expect(page.getByText('Any final thoughts?')).toBeVisible({ timeout: 3000 });
await expect(page.getByText('What would make testing better?')).not.toBeVisible();
});
test('choosing "No" skips Q2, shows Q3, shows Q4', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await waitForTransition(page);
await expect(page.getByText('Do you like testing?')).toBeVisible({ timeout: 3000 });
await page.getByRole('radio', { name: 'No' }).click();
// Radio auto-advances, so no Next click is needed here.
await expect(page.getByText('What would make testing better?')).toBeVisible({ timeout: 3000 });
await page.getByPlaceholder('Your suggestions').fill('More automation');
await waitForTransition(page);
await page
.getByRole('button', { name: /Next|Submit/ })
.first()
.click();
await expect(page.getByText('Any final thoughts?')).toBeVisible({ timeout: 3000 });
await expect(page.getByText('What do you like about testing?')).not.toBeVisible();
});
});
+685
View File
@@ -0,0 +1,685 @@
/**
* E2E tests for the Durable Object architecture.
*
* Tests that per-survey operations work correctly through both HTTP and WebSocket
* paths, including section/question CRUD, reordering, respondent flow, and results.
*/
import { test, expect } from '@playwright/test';
import { apiPost, apiPut, apiGet, apiDelete, API, ensureAuth, parseCookie } from './helpers';
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function createSurvey(slug: string) {
const survey = await apiPost('/api/surveys', { title: `DO Test ${slug}` });
const sec = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
const q1 = await apiPost(`/api/surveys/${survey.id}/sections/${sec.id}/questions`, {
text: 'Your name?',
type: 'text',
required: true,
});
const q2 = await apiPost(`/api/surveys/${survey.id}/sections/${sec.id}/questions`, {
text: 'Favorite color?',
type: 'radio',
options: [
{ label: 'Red', value: 'Red' },
{ label: 'Blue', value: 'Blue' },
],
required: true,
});
await apiPut(`/api/surveys/${survey.id}`, { slug });
await apiPut(`/api/surveys/${survey.id}/publish`);
return { surveyId: survey.id, sectionId: sec.id, q1Id: q1.id, q2Id: q2.id, slug };
}
test.describe('Section and question CRUD', () => {
let surveyId: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'CRUD Test' });
surveyId = survey.id;
});
test('create section', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'New Section' });
expect(sec.id).toBeTruthy();
expect(sec.title).toBe('New Section');
});
test('update section', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Before' });
const updated = await apiPut(`/api/surveys/${surveyId}/sections/${sec.id}`, { title: 'After' });
expect(updated.title).toBe('After');
});
test('delete section', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'To Delete' });
const res = await apiDelete(`/api/surveys/${surveyId}/sections/${sec.id}`);
expect(res.status).toBe(204);
});
test('reorder sections', async () => {
const s1 = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'First' });
const s2 = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Second' });
await apiPut(`/api/surveys/${surveyId}/sections/reorder`, {
items: [
{ id: s2.id, sort_order: 0 },
{ id: s1.id, sort_order: 1 },
],
});
const data = await apiGet(`/api/surveys/${surveyId}`);
const ordered = data.sections
.filter((s: { id: string }) => s.id === s1.id || s.id === s2.id)
.sort((a: { sort_order: number }, b: { sort_order: number }) => a.sort_order - b.sort_order);
expect(ordered[0].title).toBe('Second');
expect(ordered[1].title).toBe('First');
});
test('create question', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Q Section' });
const q = await apiPost(`/api/surveys/${surveyId}/sections/${sec.id}/questions`, {
text: 'Test Q',
type: 'text',
});
expect(q.id).toBeTruthy();
expect(q.text).toBe('Test Q');
expect(q.type).toBe('text');
});
test('update question', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Q Update' });
const q = await apiPost(`/api/surveys/${surveyId}/sections/${sec.id}/questions`, {
text: 'Before',
type: 'text',
});
const updated = await apiPut(`/api/surveys/${surveyId}/questions/${q.id}`, { text: 'After' });
expect(updated.text).toBe('After');
});
test('delete question', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Q Delete' });
const q = await apiPost(`/api/surveys/${surveyId}/sections/${sec.id}/questions`, {
text: 'To Delete',
type: 'text',
});
const res = await apiDelete(`/api/surveys/${surveyId}/questions/${q.id}`);
expect(res.status).toBe(204);
});
test('reorder questions', async () => {
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Q Reorder' });
const q1 = await apiPost(`/api/surveys/${surveyId}/sections/${sec.id}/questions`, {
text: 'First Q',
type: 'text',
});
const q2 = await apiPost(`/api/surveys/${surveyId}/sections/${sec.id}/questions`, {
text: 'Second Q',
type: 'text',
});
await apiPut(`/api/surveys/${surveyId}/sections/${sec.id}/questions/reorder`, {
items: [
{ id: q2.id, sort_order: 0 },
{ id: q1.id, sort_order: 1 },
],
});
const data = await apiGet(`/api/surveys/${surveyId}`);
const secQs = data.questions
.filter((q: { section_id: string }) => q.section_id === sec.id)
.sort((a: { sort_order: number }, b: { sort_order: number }) => a.sort_order - b.sort_order);
expect(secQs[0].text).toBe('Second Q');
expect(secQs[1].text).toBe('First Q');
});
});
test.describe('Respondent flow', () => {
const slug = `do-respondent-${Date.now()}`;
let surveyId: string;
let q1Id: string;
let q2Id: string;
test.beforeAll(async () => {
const result = await createSurvey(slug);
surveyId = result.surveyId;
q1Id = result.q1Id;
q2Id = result.q2Id;
});
test('take survey as respondent without JS errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.goto(`/s/${slug}`);
await expect(page.locator('h1')).toContainText('DO Test');
await page.click('button:has-text("Get Started")');
await page.click('button:has-text("Continue")');
await page.locator('input').first().fill('E2E Respondent');
await page.click('button:has-text("Next")');
await page.getByText('Blue', { exact: true }).click();
await page.click('button:has-text("Submit")');
await expect(page.locator('h1')).toContainText('Thank you', { timeout: 10000 });
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];
expect(ridCookie).toBeTruthy();
await fetch(`${API}/api/s/${slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: ridCookie! },
body: JSON.stringify({
answers: [
{ questionId: q1Id, value: 'API Respondent' },
{ questionId: q2Id, value: 'Red' },
],
}),
});
await fetch(`${API}/api/s/${slug}/complete`, {
method: 'POST',
headers: { Cookie: ridCookie! },
});
const results = await apiGet(`/api/surveys/${surveyId}/results`);
expect(results.respondentCounts.total).toBeGreaterThanOrEqual(1);
expect(results.respondentCounts.completed).toBeGreaterThanOrEqual(1);
expect(results.results.length).toBeGreaterThan(0);
});
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];
expect(ridCookie).toBeTruthy();
const legitId = ridCookie!.split('=')[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.
const forgedRes = await fetch(`${API}/api/s/${slug}/resume`, {
headers: { 'X-Respondent-Id': legitId },
});
expect(forgedRes.ok).toBe(true);
const forgedCookie = forgedRes.headers.get('set-cookie')?.split(';')[0];
expect(forgedCookie).toBeTruthy();
const forgedId = forgedCookie!.split('=')[1];
expect(forgedId).not.toBe(legitId);
});
});
test.describe('WebSocket protocol', () => {
const slug = `do-ws-${Date.now()}`;
let surveyId: string;
test.beforeAll(async () => {
const result = await createSurvey(slug);
surveyId = result.surveyId;
});
test('WebSocket connects and receives counts', async ({ page }) => {
await page.addInitScript(() => {
const origWS = window.WebSocket;
window.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);
});
}
} as any;
});
await page.goto(`/results/${surveyId}`);
// Wait for a "counts" push to arrive instead of blindly sleeping 3s.
await page.waitForFunction(
() =>
((window as any).__wsMessages || [])
.map((m: string) => {
try {
return JSON.parse(m);
} catch {
return null;
}
})
.some((m: any) => m && m.type === 'push' && m.event === 'counts'),
{ timeout: 10_000 },
);
const messages = await page.evaluate(() => (window 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);
expect(countsMsgs[0].data.activeViewers).toBeGreaterThanOrEqual(1);
});
});
test.describe('Editor save flow', () => {
test('create survey, add section and question, save without errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('a:has-text("New Survey")');
await page.waitForURL('**/create');
await page.click('button:has-text("Blank Survey")');
await page.fill('input[placeholder="Survey title..."]', 'E2E Save Test');
const addSectionBtn = page.locator('button:has-text("Add section")');
await addSectionBtn.scrollIntoViewIfNeeded();
await addSectionBtn.click();
await page.waitForTimeout(500);
const shortTextBtn = page.locator('button:has-text("Short Text")').first();
await shortTextBtn.scrollIntoViewIfNeeded();
await shortTextBtn.click();
await page.waitForTimeout(500);
await page.click('button:has-text("Save")');
await page.waitForURL('**/edit/**', { timeout: 10000 });
await expect(page.locator('h1')).toContainText('Edit Survey');
expect(errors).toEqual([]);
});
});
test.describe('Results page', () => {
const slug = `do-results-${Date.now()}`;
let surveyId: string;
test.beforeAll(async () => {
const result = await createSurvey(slug);
surveyId = result.surveyId;
});
test('results page loads without JS errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.goto(`/results/${surveyId}`);
await page.waitForLoadState('networkidle');
await expect(page.locator('h1')).toContainText('DO Test');
await expect(page.locator('text=Response Timeline')).toBeVisible();
expect(errors).toEqual([]);
});
});
test.describe('Survey export/import', () => {
let surveyId: string;
test.beforeAll(async () => {
const result = await createSurvey(`do-export-${Date.now()}`);
surveyId = result.surveyId;
});
test('export definition returns valid JSON', async () => {
const def = await apiGet(`/api/surveys/${surveyId}/definition`);
expect(def.version).toBe(1);
expect(def.title).toContain('DO Test');
expect(def.sections.length).toBeGreaterThan(0);
expect(def.sections[0].questions.length).toBeGreaterThan(0);
});
});
test.describe('Publish cycle', () => {
let surveyId: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Publish Test' });
surveyId = survey.id;
const sec = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'S1' });
await apiPost(`/api/surveys/${surveyId}/sections/${sec.id}/questions`, {
text: 'Q1',
type: 'text',
});
await apiPut(`/api/surveys/${surveyId}`, { slug: `pub-test-${Date.now()}` });
});
test('publish and unpublish', async () => {
const published = await apiPut(`/api/surveys/${surveyId}/publish`);
expect(published.status).toBe('published');
const unpublished = await apiPut(`/api/surveys/${surveyId}/unpublish`);
expect(unpublished.status).toBe('draft');
});
});
test.describe('Archive cycle', () => {
let surveyId: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Archive Test' });
surveyId = survey.id;
});
test('archive and unarchive', async () => {
const archived = await apiPut(`/api/surveys/${surveyId}/archive`);
expect(archived.archived_at).toBeTruthy();
const unarchived = await apiPut(`/api/surveys/${surveyId}/unarchive`);
expect(unarchived.archived_at).toBeNull();
});
});
test.describe('Duplicate survey', () => {
let surveyId: string;
test.beforeAll(async () => {
const result = await createSurvey(`dup-source-${Date.now()}`);
surveyId = result.surveyId;
});
test('duplicate creates copy with sections and questions', async () => {
const dup = await apiPost(`/api/surveys/${surveyId}/duplicate`);
expect(dup.survey.id).not.toBe(surveyId);
expect(dup.survey.title).toContain('(Copy)');
expect(dup.sections.length).toBeGreaterThan(0);
expect(dup.questions.length).toBeGreaterThan(0);
});
});
test.describe('Analytics endpoints', () => {
const slug = `do-analytics-${Date.now()}`;
let surveyId: string;
test.beforeAll(async () => {
const result = await createSurvey(slug);
surveyId = result.surveyId;
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
if (ridCookie) {
await fetch(`${API}/api/s/${slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: ridCookie },
body: JSON.stringify({ answers: [{ questionId: result.q1Id, value: 'Test' }] }),
});
await fetch(`${API}/api/s/${slug}/complete`, {
method: 'POST',
headers: { Cookie: ridCookie },
});
}
});
test('timeline returns data', async () => {
const timeline = await apiGet(`/api/surveys/${surveyId}/results/timeline?granularity=day`);
expect(Array.isArray(timeline)).toBe(true);
expect(timeline.length).toBeGreaterThan(0);
expect(timeline[0]).toHaveProperty('period');
expect(timeline[0]).toHaveProperty('started');
expect(timeline[0]).toHaveProperty('completed');
});
test('dropoff returns data', async () => {
const dropoff = await apiGet(`/api/surveys/${surveyId}/results/dropoff`);
expect(Array.isArray(dropoff)).toBe(true);
expect(dropoff.length).toBeGreaterThan(0);
expect(dropoff[0]).toHaveProperty('questionId');
expect(dropoff[0]).toHaveProperty('dropoffRate');
});
test('list respondents', async () => {
const data = await apiGet(`/api/surveys/${surveyId}/results/respondents`);
expect(data.total).toBeGreaterThanOrEqual(1);
expect(data.respondents.length).toBeGreaterThanOrEqual(1);
expect(data.respondents[0]).toHaveProperty('answerCount');
});
test('search answers', async () => {
const data = await apiGet(`/api/surveys/${surveyId}/results/search?q=Test`);
expect(data.results.length).toBeGreaterThanOrEqual(1);
expect(data.results[0].answer).toContain('Test');
});
test('get single respondent detail', async () => {
const list = await apiGet(`/api/surveys/${surveyId}/results/respondents`);
expect(list.respondents.length).toBeGreaterThan(0);
const rid = list.respondents[0].id;
const detail = await apiGet(`/api/surveys/${surveyId}/results/respondents/${rid}`);
expect(detail.id).toBe(rid);
expect(detail.answers.length).toBeGreaterThan(0);
expect(detail.answers[0]).toHaveProperty('questionId');
expect(detail.answers[0]).toHaveProperty('value');
});
test('delete respondent', async () => {
const list = await apiGet(`/api/surveys/${surveyId}/results/respondents`);
const rid = list.respondents[0].id;
const res = await apiDelete(`/api/surveys/${surveyId}/results/respondents/${rid}`);
expect(res.status).toBe(204);
const afterList = await apiGet(`/api/surveys/${surveyId}/results/respondents`);
expect(afterList.respondents.find((r: { id: string }) => r.id === rid)).toBeUndefined();
});
});
test.describe('Survey import', () => {
test('import survey definition', async () => {
const definition = {
version: 1,
title: 'Imported Survey',
description: 'Imported via E2E test',
sections: [
{
title: 'Imported Section',
questions: [
{ text: 'Imported Q1', type: 'text', required: true },
{
text: 'Imported Q2',
type: 'radio',
options: [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
],
},
],
},
],
};
const result = await apiPost('/api/surveys/import', definition);
expect(result.survey.title).toBe('Imported Survey');
expect(result.sections.length).toBe(1);
expect(result.questions.length).toBe(2);
expect(result.questions[0].text).toBe('Imported Q1');
});
});
test.describe('Survey deletion', () => {
test('delete survey removes it', async () => {
const survey = await apiPost('/api/surveys', { title: 'To Be Deleted' });
const res = await apiDelete(`/api/surveys/${survey.id}`);
expect(res.status).toBe(204);
});
});
test.describe('Respondent reset', () => {
const slug = `do-reset-${Date.now()}`;
test.beforeAll(async () => {
await createSurvey(slug);
});
test('reset clears respondent cookie', async () => {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
expect(ridCookie).toBeTruthy();
const resetRes = await fetch(`${API}/api/s/${slug}/reset`, {
method: 'POST',
headers: { Cookie: ridCookie! },
});
expect(resetRes.status).toBe(204);
const clearCookie = resetRes.headers.get('set-cookie');
expect(clearCookie).toContain('Max-Age=0');
});
});
test.describe('WebSocket typed operations', () => {
const slug = `do-ws-ops-${Date.now()}`;
test.beforeAll(async () => {
await createSurvey(slug);
});
test('WS get-survey returns survey with sections and questions', async ({ page }) => {
await page.goto('/');
const result = await page.evaluate(
async ({ slug }) => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=editor`,
);
ws.onopen = () => {
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') {
ws.close();
resolve(msg);
}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
},
{ slug },
);
const msg = result as {
op: string;
data: { survey: { title: string }; sections: unknown[]; questions: unknown[] };
};
expect(msg.op).toBe('get-survey');
expect(msg.data.survey.title).toContain('DO Test');
expect(msg.data.sections.length).toBeGreaterThan(0);
expect(msg.data.questions.length).toBeGreaterThan(0);
});
test('WS create-section and delete-section', async ({ page }) => {
await page.goto('/');
const result = await page.evaluate(
async ({ slug }) => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=editor`,
);
const responses: Record<string, unknown> = {};
ws.onopen = () => {
ws.send(
JSON.stringify({
type: 'request',
requestId: 'create',
op: 'create-section',
data: { title: 'WS Created Section' },
}),
);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'response') {
responses[msg.requestId] = msg;
if (msg.requestId === 'create' && !msg.error) {
ws.send(
JSON.stringify({
type: 'request',
requestId: 'delete',
op: 'delete-section',
data: { id: (msg.data as { id: string }).id },
}),
);
}
if (msg.requestId === 'delete') {
ws.close();
resolve(responses);
}
}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
},
{ slug },
);
const msgs = result as Record<string, { data: { title?: string }; error?: string }>;
expect(msgs.create.data.title).toBe('WS Created Section');
expect(msgs.delete.error).toBeUndefined();
});
test('WS get-results returns aggregated data', async ({ page }) => {
await page.goto('/');
const result = await page.evaluate(
async ({ slug }) => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=viewer`,
);
ws.onopen = () => {
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') {
ws.close();
resolve(msg);
}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
},
{ slug },
);
const msg = result as { data: { respondentCounts: { total: number }; results: unknown[] } };
expect(msg.data.respondentCounts).toHaveProperty('total');
expect(msg.data.respondentCounts).toHaveProperty('completed');
expect(Array.isArray(msg.data.results)).toBe(true);
});
test('WS receives push events', async ({ page }) => {
await page.goto('/');
const pushEvents = await page.evaluate(
async ({ slug }) => {
return new Promise((resolve, reject) => {
const events: unknown[] = [];
const ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=viewer`,
);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'push') events.push(msg);
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => {
ws.close();
resolve(events);
}, 4000);
});
},
{ slug },
);
const events = pushEvents as Array<{ event: string; data: unknown }>;
expect(events.length).toBeGreaterThan(0);
const countsEvent = events.find((e) => e.event === 'counts');
expect(countsEvent).toBeTruthy();
expect(countsEvent!.data).toHaveProperty('activeViewers');
});
});
@@ -0,0 +1,631 @@
import { test, expect, type Page } from '@playwright/test';
import { apiPost, apiPut, API, ensureAuth, getAuthHeaders, parseCookie } from './helpers';
interface SetupResult {
surveyId: string;
slug: string;
questionIds: Record<string, string>;
}
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function createFullSurvey(): Promise<SetupResult> {
const survey = await apiPost('/api/surveys', { title: 'E2E All 10 Types' });
const surveyId = survey.id;
const section1 = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Choice Questions' });
const section2 = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Input Questions' });
const section3 = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Scale Questions' });
const questions = [
{
key: 'radio',
sectionId: section1.id,
body: {
text: 'Pick a color',
type: 'radio',
options: [
{ label: 'Red', value: 'Red' },
{ label: 'Blue', value: 'Blue' },
],
required: true,
},
},
{
key: 'checkbox',
sectionId: section1.id,
body: {
text: 'Select hobbies',
type: 'checkbox',
options: [
{ label: 'Reading', value: 'Reading' },
{ label: 'Gaming', value: 'Gaming' },
],
required: true,
},
},
{
key: 'dropdown',
sectionId: section1.id,
body: {
text: 'Choose a country',
type: 'dropdown',
options: [
{ label: 'USA', value: 'USA' },
{ label: 'UK', value: 'UK' },
{ label: 'Germany', value: 'Germany' },
],
required: true,
},
},
{
key: 'text',
sectionId: section2.id,
body: { text: 'Your name', type: 'text', required: true, placeholder: 'Enter name' },
},
{
key: 'number',
sectionId: section2.id,
body: { text: 'Your age', type: 'number', required: true, config: { min: 0, max: 120 } },
},
{
key: 'email',
sectionId: section2.id,
body: { text: 'Your email', type: 'email', required: true, placeholder: 'you@example.com' },
},
{ key: 'textarea', sectionId: section2.id, body: { text: 'Tell us more', type: 'textarea', required: false } },
{
key: 'rating',
sectionId: section3.id,
body: { text: 'Rate our service', type: 'rating', required: true, config: { scaleMax: 5 } },
},
{ key: 'nps', sectionId: section3.id, body: { text: 'How likely to recommend?', type: 'nps', required: true } },
{ key: 'likert', sectionId: section3.id, body: { text: 'I am satisfied', type: 'likert', required: true } },
];
const questionIds: Record<string, string> = {};
for (const q of questions) {
const created = await apiPost(`/api/surveys/${surveyId}/sections/${q.sectionId}/questions`, q.body);
questionIds[q.key] = created.id;
}
const slug = `e2e-full-${Date.now()}`;
await apiPut(`/api/surveys/${surveyId}`, { slug });
await apiPut(`/api/surveys/${surveyId}/publish`);
return { surveyId, slug, questionIds };
}
async function waitForTransition(page: Page) {
await page.waitForTimeout(400);
}
async function clickNext(page: Page, label = 'Next') {
await waitForTransition(page);
await page.getByRole('button', { name: label }).first().click();
}
async function dismissSectionHeader(page: Page) {
await page.getByRole('button', { name: 'Continue' }).click();
}
test.describe('Full survey with all 10 question types', () => {
let setup: SetupResult;
test.beforeAll(async () => {
setup = await createFullSurvey();
});
test('completes survey with all question types', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await expect(page.getByText('Pick a color')).toBeVisible({ timeout: 3000 });
await page.getByRole('radio', { name: 'Blue' }).click();
// Auto-advances
await expect(page.getByText('Select hobbies')).toBeVisible({ timeout: 3000 });
await waitForTransition(page);
await page.getByRole('checkbox', { name: 'Reading' }).click();
await page.getByRole('checkbox', { name: 'Gaming' }).click();
await clickNext(page);
await expect(page.getByText('Choose a country')).toBeVisible({ timeout: 3000 });
await waitForTransition(page);
await page.locator('select').selectOption('Germany');
await clickNext(page);
await dismissSectionHeader(page);
await expect(page.getByText('Your name')).toBeVisible({ timeout: 3000 });
await page.getByPlaceholder('Enter name').fill('E2E Tester');
await clickNext(page);
await expect(page.getByText('Your age')).toBeVisible({ timeout: 3000 });
await waitForTransition(page);
await page.locator('input[type="number"]').fill('30');
await clickNext(page);
await expect(page.getByText('Your email')).toBeVisible({ timeout: 3000 });
await page.getByPlaceholder('you@example.com').fill('e2e@test.com');
await clickNext(page);
await expect(page.getByText('Tell us more')).toBeVisible({ timeout: 3000 });
await clickNext(page, 'Skip');
await dismissSectionHeader(page);
await expect(page.getByText('Rate our service')).toBeVisible({ timeout: 3000 });
await waitForTransition(page);
await page.locator('[data-rating-value="4"]').click();
await clickNext(page);
await expect(page.getByText('How likely to recommend?')).toBeVisible({ timeout: 3000 });
await waitForTransition(page);
await page.getByLabel('Score 9').click();
await clickNext(page);
await expect(page.getByText('I am satisfied')).toBeVisible({ timeout: 3000 });
await waitForTransition(page);
await page.getByRole('button', { name: 'Agree', exact: true }).click();
await clickNext(page, 'Submit');
await expect(page.getByText('Thank you!')).toBeVisible({ timeout: 5000 });
});
test('results contain all question types', async () => {
const resumeRes = await fetch(`${API}/api/s/${setup.slug}/resume`);
const cookies = resumeRes.headers.get('set-cookie') ?? '';
const ridMatch = cookies.match(/rid_[^=]+=([^;]+)/);
const rid = ridMatch?.[1];
if (rid) {
const answers = [
{ questionId: setup.questionIds.radio, value: 'Red' },
{ questionId: setup.questionIds.checkbox, value: 'Reading' },
{ questionId: setup.questionIds.dropdown, value: 'USA' },
{ questionId: setup.questionIds.text, value: 'API User' },
{ questionId: setup.questionIds.number, value: '25' },
{ questionId: setup.questionIds.email, value: 'api@test.com' },
{ questionId: setup.questionIds.rating, value: '5' },
{ questionId: setup.questionIds.nps, value: '10' },
{ questionId: setup.questionIds.likert, value: 'Strongly Agree' },
];
await fetch(`${API}/api/s/${setup.slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: `rid_${setup.slug}=${rid}` },
body: JSON.stringify({ answers }),
});
await fetch(`${API}/api/s/${setup.slug}/complete`, {
method: 'POST',
headers: { Cookie: `rid_${setup.slug}=${rid}` },
});
}
const res = await fetch(`${API}/api/surveys/${setup.surveyId}/results`, {
headers: getAuthHeaders(),
});
const data = await res.json();
expect(data.respondentCounts.completed).toBeGreaterThanOrEqual(1);
expect(data.results.length).toBeGreaterThanOrEqual(5);
});
test('CSV export works', async () => {
const res = await fetch(`${API}/api/surveys/${setup.surveyId}/results/export?format=csv`, {
headers: getAuthHeaders(),
});
expect(res.ok).toBe(true);
expect(res.headers.get('content-type')).toContain('text/csv');
const csv = await res.text();
expect(csv).toContain('respondent_id');
expect(csv.split('\n').length).toBeGreaterThan(1);
});
test('JSON export works', async () => {
const res = await fetch(`${API}/api/surveys/${setup.surveyId}/results/export?format=json`, {
headers: getAuthHeaders(),
});
expect(res.ok).toBe(true);
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data[0]).toHaveProperty('respondentId');
expect(data[0]).toHaveProperty('answers');
});
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('Completed')).toBeVisible();
await expect(page.getByText('Completion')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Pick a color' })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'CSV' })).toBeVisible();
await expect(page.getByRole('button', { name: 'JSON' })).toBeVisible();
});
});
test.describe('Survey duplication', () => {
test('duplicates a survey via API', async () => {
const survey = await apiPost('/api/surveys', { title: 'Original Survey' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Q1',
type: 'radio',
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
],
});
const result = await apiPost(`/api/surveys/${survey.id}/duplicate`);
const dup = result.survey ?? result;
expect(dup.title).toBe('Original Survey (Copy)');
expect(dup.status).toBe('draft');
expect(dup.id).not.toBe(survey.id);
// Retry: the duplicated survey's DO initialises asynchronously.
let data: Record<string, unknown[]> = { sections: [], questions: [] };
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API}/api/surveys/${dup.id}`, {
headers: getAuthHeaders(),
});
data = await res.json();
if (data.sections?.length > 0) break;
await new Promise((r) => setTimeout(r, 1000));
}
expect(data.sections.length).toBe(1);
expect(data.questions.length).toBe(1);
});
test('duplicate button works on dashboard', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Surveys' })).toBeVisible();
const initialCards = await page.locator('[class*="card-hover"]').count();
const duplicateBtn = page.locator('button[title="Duplicate"]').first();
if (await duplicateBtn.isVisible()) {
await duplicateBtn.click();
await expect(page.locator('[class*="card-hover"]')).toHaveCount(initialCards + 1, { timeout: 5000 });
}
});
});
test.describe('Embed page', () => {
let slug: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Embed Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'S1' });
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Quick Q',
type: 'text',
required: true,
});
slug = `embed-test-${Date.now()}`;
await apiPut(`/api/surveys/${survey.id}`, { slug });
await apiPut(`/api/surveys/${survey.id}/publish`);
});
test('embed page loads the survey', async ({ page }) => {
await page.goto(`/embed/${slug}`);
await expect(page.getByText('Embed Test')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: 'Get Started' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Quick Q')).toBeVisible({ timeout: 3000 });
});
});
test.describe('Survey with skip logic across question types', () => {
let setup: { surveyId: string; slug: string };
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Skip Logic Flow Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Feedback' });
const q1 = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Have you used our product?',
type: 'radio',
required: true,
options: [
{ label: 'Yes', value: 'Yes' },
{ label: 'No', value: 'No' },
],
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Rate your experience',
type: 'rating',
required: true,
config: { scaleMax: 5 },
conditional: { showIf: { questionId: q1.id, condition: 'equals', value: 'Yes' } },
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'What prevented you from trying it?',
type: 'text',
required: true,
placeholder: 'Tell us why',
conditional: { showIf: { questionId: q1.id, condition: 'equals', value: 'No' } },
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'How likely to recommend?',
type: 'nps',
required: true,
});
const slug = `e2e-skip-flow-${Date.now()}`;
await apiPut(`/api/surveys/${survey.id}`, { slug });
await apiPut(`/api/surveys/${survey.id}/publish`);
setup = { surveyId: survey.id, slug };
});
test('skip logic works: Yes path shows rating, skips text', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await expect(page.getByText('Have you used our product?')).toBeVisible({ timeout: 3000 });
await page.getByRole('radio', { name: 'Yes' }).click();
await expect(page.getByText('Rate your experience')).toBeVisible({ timeout: 3000 });
await expect(page.getByText('What prevented you from trying it?')).not.toBeVisible();
await page.locator('[data-rating-value="4"]').click();
await clickNext(page);
await expect(page.getByText('How likely to recommend?')).toBeVisible({ timeout: 3000 });
await page.getByLabel('Score 8').click();
await clickNext(page, 'Submit');
await expect(page.getByText('Thank you!')).toBeVisible({ timeout: 5000 });
});
test('skip logic works: No path shows text, skips rating', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await expect(page.getByText('Have you used our product?')).toBeVisible({ timeout: 3000 });
await page.getByRole('radio', { name: 'No' }).click();
await expect(page.getByText('What prevented you from trying it?')).toBeVisible({ timeout: 3000 });
await expect(page.getByText('Rate your experience')).not.toBeVisible();
await page.getByPlaceholder('Tell us why').fill('No time');
await clickNext(page);
await expect(page.getByText('How likely to recommend?')).toBeVisible({ timeout: 3000 });
await page.getByLabel('Score 5').click();
await clickNext(page, 'Submit');
await expect(page.getByText('Thank you!')).toBeVisible({ timeout: 5000 });
});
test('skipped questions are not in results', async () => {
// Create a fresh survey to avoid interference from browser tests
const survey = await apiPost('/api/surveys', { title: 'Skip Results Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'S1' });
const q1 = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Used product?',
type: 'radio',
required: true,
options: [
{ label: 'Yes', value: 'Yes' },
{ label: 'No', value: 'No' },
],
});
const qRating = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Rate it',
type: 'rating',
required: true,
config: { scaleMax: 5 },
conditional: { showIf: { questionId: q1.id, condition: 'equals', value: 'Yes' } },
});
const qText = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Why not?',
type: 'text',
required: true,
conditional: { showIf: { questionId: q1.id, condition: 'equals', value: 'No' } },
});
const qNps = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Recommend?',
type: 'nps',
required: true,
});
const slug = `e2e-skip-results-${Date.now()}`;
await apiPut(`/api/surveys/${survey.id}`, { slug });
await apiPut(`/api/surveys/${survey.id}/publish`);
async function submitResponse(answers: Array<{ questionId: string; value: string }>) {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const cookies = resumeRes.headers.get('set-cookie') ?? '';
const ridMatch = cookies.match(/rid_[^=]+=([^;]+)/);
const rid = ridMatch?.[1];
expect(rid).toBeTruthy();
const cookie = `rid_${slug}=${rid}`;
await fetch(`${API}/api/s/${slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ answers }),
});
await fetch(`${API}/api/s/${slug}/complete`, {
method: 'POST',
headers: { Cookie: cookie },
});
}
await submitResponse([
{ questionId: q1.id, value: 'Yes' },
{ questionId: qRating.id, value: '4' },
{ questionId: qNps.id, value: '8' },
]);
await submitResponse([
{ questionId: q1.id, value: 'No' },
{ questionId: qText.id, value: 'No time' },
{ questionId: qNps.id, value: '5' },
]);
const res = await fetch(`${API}/api/surveys/${survey.id}/results`, {
headers: getAuthHeaders(),
});
const data = await res.json();
expect(data.respondentCounts.completed).toBe(2);
// Rating question should have 1 response (only the "Yes" path respondent)
const ratingResult = data.results.find((r: { questionId: string }) => r.questionId === qRating.id);
if (ratingResult) {
const totalRatingResponses = ratingResult.answers.reduce((sum: number, a: { count: number }) => sum + a.count, 0);
expect(totalRatingResponses).toBe(1);
}
});
});
test.describe('Enter key advances on single-line inputs', () => {
let slug: string;
test.beforeAll(async () => {
const survey = await apiPost('/api/surveys', { title: 'Enter Key Test' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Inputs' });
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Your name',
type: 'text',
required: true,
placeholder: 'Your name',
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Your email',
type: 'email',
required: true,
placeholder: 'you@example.com',
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Your age',
type: 'number',
required: true,
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Any comments',
type: 'textarea',
required: false,
placeholder: 'Tell us more',
});
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Rate us',
type: 'rating',
required: true,
config: { scaleMax: 5 },
});
slug = `enter-key-${Date.now()}`;
await apiPut(`/api/surveys/${survey.id}`, { slug });
await apiPut(`/api/surveys/${survey.id}/publish`);
});
async function startSurveyAtFirstQuestion(page: Page) {
await page.goto(`/s/${slug}`);
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await expect(page.getByRole('heading', { name: 'Your name' })).toBeVisible({ timeout: 5000 });
}
test('Enter on text input advances to the next question', async ({ page }) => {
await startSurveyAtFirstQuestion(page);
const nameInput = page.getByPlaceholder('Your name');
await nameInput.fill('Alice');
await nameInput.press('Enter');
await expect(page.getByRole('heading', { name: 'Your email' })).toBeVisible({ timeout: 5000 });
});
test('Enter on email and number inputs advances', async ({ page }) => {
await startSurveyAtFirstQuestion(page);
await page.getByPlaceholder('Your name').fill('Bob');
await page.getByPlaceholder('Your name').press('Enter');
await expect(page.getByRole('heading', { name: 'Your email' })).toBeVisible({ timeout: 5000 });
await waitForTransition(page);
await page.getByPlaceholder('you@example.com').fill('bob@test.com');
await page.getByPlaceholder('you@example.com').press('Enter');
await expect(page.getByRole('heading', { name: 'Your age' })).toBeVisible({ timeout: 5000 });
await waitForTransition(page);
await page.locator('input[type="number"]').fill('30');
await page.locator('input[type="number"]').press('Enter');
await expect(page.getByRole('heading', { name: 'Any comments' })).toBeVisible({ timeout: 5000 });
});
test('Enter in textarea inserts a newline instead of advancing', async ({ page }) => {
await startSurveyAtFirstQuestion(page);
// Race through to the textarea question via the regular Next button so we
// don't depend on the feature under test.
await page.getByPlaceholder('Your name').fill('Carol');
await clickNext(page);
await expect(page.getByRole('heading', { name: 'Your email' })).toBeVisible({ timeout: 5000 });
await page.getByPlaceholder('you@example.com').fill('carol@test.com');
await clickNext(page);
await expect(page.getByRole('heading', { name: 'Your age' })).toBeVisible({ timeout: 5000 });
await page.locator('input[type="number"]').fill('25');
await clickNext(page);
await expect(page.getByRole('heading', { name: 'Any comments' })).toBeVisible({ timeout: 5000 });
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible({ timeout: 5000 });
await textarea.fill('line one');
await textarea.press('Enter');
await textarea.pressSequentially('line two');
// Enter must not have advanced us
await expect(page.getByRole('heading', { name: 'Any comments' })).toBeVisible();
const value = await textarea.inputValue();
expect(value).toBe('line one\nline two');
});
});
test.describe('Survey builder with new types', () => {
test('can add all question types in the builder', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'New Survey' }).first().click();
await page.getByRole('button', { name: 'Blank Survey' }).click();
await page.getByPlaceholder('Survey title...').fill('Builder Types Test');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByText('Add section').click();
await page.getByPlaceholder('e.g., About You').fill('All Types');
const typesToAdd = ['Rating', 'NPS', 'Number', 'Dropdown', 'Likert'];
for (const type of typesToAdd) {
// The add-question chips are the last buttons with these names on the page
await page.getByRole('button', { name: type }).last().click();
await page.waitForTimeout(300);
}
// The new section starts empty, so exactly 5 questions are expected.
await expect(page.getByText('5 questions', { exact: true })).toBeVisible({ timeout: 3000 });
});
});
@@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test';
import { apiPost, apiPut, apiRawGet, API, ensureAuth, parseCookie } from './helpers';
interface SurveySetup {
surveyId: string;
slug: string;
questionIds: string[];
}
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function createSurveyWithQuestion(opts: {
title: string;
slug: string;
maxResponses?: number;
closesAt?: string;
}): Promise<SurveySetup> {
const survey = await apiPost('/api/surveys', { title: opts.title });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
const question = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'Pick one',
type: 'radio',
required: true,
options: [
{ label: 'Yes', value: 'Yes' },
{ label: 'No', value: 'No' },
],
});
await apiPut(`/api/surveys/${survey.id}`, {
slug: opts.slug,
...(opts.maxResponses !== undefined ? { max_responses: opts.maxResponses } : {}),
...(opts.closesAt ? { closes_at: opts.closesAt } : {}),
});
await apiPut(`/api/surveys/${survey.id}/publish`);
return { surveyId: survey.id, slug: opts.slug, questionIds: [question.id] };
}
async function submitOneResponse(slug: string, questionId: string): Promise<void> {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const cookies = resumeRes.headers.get('set-cookie') ?? '';
const ridMatch = cookies.match(/rid_[^=]+=([^;]+)/);
const rid = ridMatch?.[1];
expect(rid).toBeTruthy();
await fetch(`${API}/api/s/${slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: `rid_${slug}=${rid}` },
body: JSON.stringify({ answers: [{ questionId, value: 'Yes' }] }),
});
await fetch(`${API}/api/s/${slug}/complete`, {
method: 'POST',
headers: { Cookie: `rid_${slug}=${rid}` },
});
}
test.describe('Survey with max_responses limit', () => {
let setup: SurveySetup;
test.beforeAll(async () => {
const slug = `e2e-maxresp-${Date.now()}`;
setup = await createSurveyWithQuestion({
title: 'Max Responses Limit Test',
slug,
maxResponses: 1,
});
await submitOneResponse(slug, setup.questionIds[0]);
});
test('API blocks resume after max_responses reached', async () => {
const resumeRes = await apiRawGet(`/api/s/${setup.slug}/resume`);
expect(resumeRes.status).toBe(403);
const body = await resumeRes.json();
expect(body.error).toContain('maximum');
});
test('shows error in browser after max_responses reached', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await expect(
page.locator('text=maximum').or(page.locator('text=closed')).or(page.locator('text=Failed to load survey')),
).toBeVisible({
timeout: 10000,
});
});
});
test.describe.serial('Survey with past closes_at date', () => {
let setup: SurveySetup;
test.beforeAll(async () => {
const slug = `e2e-closed-${Date.now()}`;
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
setup = await createSurveyWithQuestion({
title: 'Closed Survey Test',
slug,
closesAt: oneDayAgo,
});
});
test('API blocks resume on a closed survey', async () => {
const res = await apiRawGet(`/api/s/${setup.slug}/resume`);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain('closed');
});
test('shows closed message in browser', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await expect(page.locator('text=closed').or(page.locator('text=Failed to load survey'))).toBeVisible({
timeout: 10000,
});
});
});
@@ -0,0 +1,108 @@
import { test, expect, type Page } from '@playwright/test';
import { apiPost, apiPut, ensureAuth, parseCookie } from './helpers';
interface SetupResult {
surveyId: string;
slug: string;
sectionId: string;
questionId: string;
}
const SURVEY_PASSWORD = 'test-survey-pass-42';
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function waitForTransition(page: Page) {
await page.waitForTimeout(400);
}
async function dismissSectionHeader(page: Page) {
await page.getByRole('button', { name: 'Continue' }).click();
}
async function createPasswordProtectedSurvey(): Promise<SetupResult> {
const slug = `e2e-pwd-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const survey = await apiPost('/api/surveys', { title: 'Password Protected Survey' });
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
const question = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
text: 'What is your name?',
type: 'text',
required: true,
placeholder: 'Enter your name',
});
await apiPut(`/api/surveys/${survey.id}`, { slug, password: SURVEY_PASSWORD });
await apiPut(`/api/surveys/${survey.id}/publish`);
return { surveyId: survey.id, slug, sectionId: section.id, questionId: question.id };
}
test.describe.serial('Password-protected survey', () => {
let setup: SetupResult;
test.beforeAll(async () => {
setup = await createPasswordProtectedSurvey();
});
test('visiting the public URL shows the password gate', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await expect(page.getByText('This survey is password protected')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Password Protected Survey')).toBeVisible();
await expect(page.getByPlaceholder('Enter password')).toBeVisible();
});
test('entering wrong password shows an error', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await expect(page.getByText('This survey is password protected')).toBeVisible({ timeout: 5000 });
await page.getByPlaceholder('Enter password').fill('wrong-password');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText(/incorrect|invalid|wrong/i)).toBeVisible({ timeout: 5000 });
await expect(page.getByText('This survey is password protected')).toBeVisible();
});
test('entering correct password allows access to the survey', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await expect(page.getByText('This survey is password protected')).toBeVisible({ timeout: 5000 });
await page.getByPlaceholder('Enter password').fill(SURVEY_PASSWORD);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Get Started' })).toBeVisible({ timeout: 5000 });
});
test('full flow: password -> fill survey -> submit', async ({ page }) => {
await page.goto(`/s/${setup.slug}`);
await expect(page.getByText('This survey is password protected')).toBeVisible({ timeout: 5000 });
await page.getByPlaceholder('Enter password').fill(SURVEY_PASSWORD);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Get Started' })).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: 'Get Started' }).click();
await dismissSectionHeader(page);
await waitForTransition(page);
await expect(page.getByText('What is your name?')).toBeVisible({ timeout: 3000 });
await page.getByPlaceholder('Enter your name').fill('E2E Password Tester');
await waitForTransition(page);
await page.getByRole('button', { name: 'Submit' }).first().click();
await expect(page.getByText('Thank you!')).toBeVisible({ timeout: 5000 });
});
});
@@ -0,0 +1,222 @@
import { test, expect } from '@playwright/test';
import { apiPost, apiPut, API, ensureAuth, getAuthHeaders, parseCookie } from './helpers';
interface SetupResult {
surveyId: string;
slug: string;
questionIds: Record<string, string>;
}
let cookie: string;
test.beforeAll(async () => {
cookie = await ensureAuth();
});
test.beforeEach(async ({ context }) => {
const BASE = process.env.BASE_URL || 'http://localhost:5173';
const { name, value } = parseCookie(cookie);
await context.addCookies([{ name, value, url: BASE }]);
});
async function createResultsSurvey(): Promise<SetupResult> {
const survey = await apiPost('/api/surveys', { title: 'Results Features Test' });
const surveyId = survey.id;
const section = await apiPost(`/api/surveys/${surveyId}/sections`, { title: 'Feedback' });
const questionIds: Record<string, string> = {};
const q1 = await apiPost(`/api/surveys/${surveyId}/sections/${section.id}/questions`, {
text: 'Favorite color',
type: 'radio',
required: true,
options: [
{ label: 'Red', value: 'Red' },
{ label: 'Blue', value: 'Blue' },
{ label: 'Green', value: 'Green' },
],
});
questionIds['color'] = q1.id;
const q2 = await apiPost(`/api/surveys/${surveyId}/sections/${section.id}/questions`, {
text: 'Your name',
type: 'text',
required: true,
placeholder: 'Enter your name',
});
questionIds['name'] = q2.id;
const q3 = await apiPost(`/api/surveys/${surveyId}/sections/${section.id}/questions`, {
text: 'Rate our service',
type: 'rating',
required: true,
config: { scaleMax: 5 },
});
questionIds['rating'] = q3.id;
const slug = `e2e-results-${Date.now()}`;
await apiPut(`/api/surveys/${surveyId}`, { slug });
await apiPut(`/api/surveys/${surveyId}/publish`);
return { surveyId, slug, questionIds };
}
async function submitResponse(slug: string, answers: Array<{ questionId: string; value: string }>): Promise<void> {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const cookies = resumeRes.headers.get('set-cookie') ?? '';
const ridMatch = cookies.match(/rid_[^=]+=([^;]+)/);
const rid = ridMatch?.[1];
expect(rid).toBeTruthy();
await fetch(`${API}/api/s/${slug}/answers/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: `rid_${slug}=${rid}` },
body: JSON.stringify({ answers }),
});
await fetch(`${API}/api/s/${slug}/complete`, {
method: 'POST',
headers: { Cookie: `rid_${slug}=${rid}` },
});
}
test.describe.serial('Results page features', () => {
let setup: SetupResult;
test.beforeAll(async () => {
setup = await createResultsSurvey();
await submitResponse(setup.slug, [
{ questionId: setup.questionIds.color, value: 'Red' },
{ questionId: setup.questionIds.name, value: 'Alice Johnson' },
{ questionId: setup.questionIds.rating, value: '5' },
]);
await submitResponse(setup.slug, [
{ questionId: setup.questionIds.color, value: 'Blue' },
{ questionId: setup.questionIds.name, value: 'Bob Smith' },
{ questionId: setup.questionIds.rating, value: '4' },
]);
await submitResponse(setup.slug, [
{ questionId: setup.questionIds.color, value: 'Red' },
{ questionId: setup.questionIds.name, value: 'Charlie Davis' },
{ questionId: setup.questionIds.rating, value: '3' },
]);
});
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('Completed')).toBeVisible();
await expect(page.getByText('Completion')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Favorite color' })).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole('heading', { name: 'Your name' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Rate our service' })).toBeVisible();
await expect(page.getByRole('button', { name: 'CSV' })).toBeVisible();
await expect(page.getByRole('button', { name: 'JSON' })).toBeVisible();
});
test('responses tab shows respondent list', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: 'Responses' }).click();
await expect(page.getByText('Respondent')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Status')).toBeVisible();
await expect(page.getByText('Answers')).toBeVisible();
const completeLabels = page.locator('text=Complete');
await expect(completeLabels.first()).toBeVisible({ timeout: 5000 });
expect(await completeLabels.count()).toBeGreaterThanOrEqual(3);
});
test('search tab finds text answers', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByPlaceholder('Search answers...')).toBeVisible({ timeout: 5000 });
await page.getByPlaceholder('Search answers...').fill('Alice');
await expect(page.getByText('Alice Johnson')).toBeVisible({ timeout: 5000 });
// Scoped to the results list: 'Your name' also appears in the filter dropdown.
await expect(page.locator('.space-y-2 >> text=Your name')).toBeVisible();
await expect(page.getByText(/Showing \d+ of \d+ result/)).toBeVisible();
});
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 page.getByRole('button', { name: 'Search' }).click();
await page.getByPlaceholder('Search answers...').fill('Alice');
await expect(page.getByText('Alice Johnson')).toBeVisible({ timeout: 5000 });
// Precondition for the assertion further down: "Red" is not in the search results.
await expect(page.getByText('Red', { exact: true })).toHaveCount(0);
const resultRow = page.locator('button', { has: page.getByText('Alice Johnson') }).first();
await resultRow.click();
// The matched question gets a "match" chip in the expanded detail.
await expect(page.getByText('match', { exact: true })).toBeVisible({ timeout: 5000 });
// "Red" appearing now proves the expansion pulled the full respondent record.
await expect(page.getByText('Red', { exact: true })).toBeVisible();
await resultRow.click();
await expect(page.getByText('match', { exact: true })).toHaveCount(0);
});
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.getByRole('heading', { name: 'Time to Complete' })).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('heading', { name: 'Time per Question' })).toBeVisible();
});
test('CSV export triggers download', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
// Set up download listener before clicking
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.getByRole('button', { name: 'CSV' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});
test('results API returns correct data', async () => {
const res = await fetch(`${API}/api/surveys/${setup.surveyId}/results`, {
headers: getAuthHeaders(),
});
expect(res.ok).toBe(true);
const data = await res.json();
expect(data.respondentCounts.completed).toBe(3);
expect(data.respondentCounts.total).toBeGreaterThanOrEqual(3);
expect(data.results.length).toBe(3); // 3 questions
});
test('search API returns matching answers', async () => {
const res = await fetch(`${API}/api/surveys/${setup.surveyId}/results/search?q=Bob`, { headers: getAuthHeaders() });
expect(res.ok).toBe(true);
const data = await res.json();
expect(data.total).toBeGreaterThanOrEqual(1);
expect(data.results[0].answer).toContain('Bob');
});
});
+46
View File
@@ -0,0 +1,46 @@
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/'],
},
];
+59
View File
@@ -0,0 +1,59 @@
{
"name": "survey.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",
"db:migrate:local": "cd backend && npx wrangler d1 migrations apply survey --local",
"test:e2e": "playwright test"
},
"devDependencies": {
"@eslint/js": "^10.0.0",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.13",
"@types/d3-cloud": "^1.2.9",
"@types/d3-selection": "^3.0.11",
"@types/node": "^24.12.0",
"@types/oidc-provider": "^9.5.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",
"oidc-provider": "^9.7.1",
"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/kysely-adapter-cloudflare": "^0.1.0",
"@immich/ui": "^0.65.0",
"@mdi/js": "^7.4.47",
"@types/qrcode": "^1.5.6",
"chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0",
"d3-cloud": "^1.2.9",
"d3-selection": "^3.0.0",
"date-fns": "^4.1.0",
"jspdf": "^4.2.1",
"kysely": "^0.28.15",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.69"
}
}
@@ -0,0 +1,23 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '*.e2e.ts',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false,
// Retries in CI only, so local runs surface real regressions instead of
// masking them.
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5173',
headless: true,
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
});
@@ -0,0 +1,242 @@
/**
* Shared answer validation — the same rules run on the client (QuestionCard)
* and the server (ws-handler, respondent.service). Returns null when valid,
* otherwise a human-readable error string.
*/
const LIKERT_VALUES = ['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree'];
/**
* Hard cap independent of a question's configured maxLength: without it a
* text/textarea question with no maxLength lets a caller persist an unbounded
* value into DO SQLite — a storage-exhaustion vector.
*/
export const MAX_ANSWER_LENGTH = 20_000;
export interface QuestionSpec {
type: string;
required: boolean;
options?: Array<{ value: string }>;
hasOther?: boolean;
maxLength?: number;
config?: {
// Number
min?: number;
max?: number;
integerOnly?: boolean;
step?: number;
// Rating
scaleMax?: number;
// Text / textarea
minLength?: number;
pattern?: string;
patternError?: string;
minWords?: number;
maxWords?: number;
// Checkbox
minSelections?: number;
maxSelections?: number;
// Email
allowedDomains?: string[];
};
}
function wordCount(text: string): number {
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
}
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);
// Absolute length ceiling, enforced before any per-type logic so it applies
// even to types/configs that would otherwise accept unbounded input.
if (value.length > MAX_ANSWER_LENGTH || (otherText !== undefined && otherText.length > MAX_ANSWER_LENGTH)) {
return `Answer must be at most ${MAX_ANSWER_LENGTH} characters`;
}
const trimmed = value.trim();
const cfg = question.config ?? {};
if (question.required && trimmed === '') {
return 'This question is required';
}
if (trimmed === '') return null;
switch (question.type) {
case 'text':
case 'textarea':
return validateText(trimmed, question, cfg);
case 'email':
return validateEmail(trimmed, cfg);
case 'number':
return validateNumber(trimmed, cfg);
case 'rating':
return validateRating(trimmed, cfg);
case 'nps':
return validateNps(trimmed);
case 'likert':
return LIKERT_VALUES.includes(trimmed) ? null : 'Please select a valid option';
case 'radio':
return validateRadio(trimmed, question, otherText);
case 'checkbox':
return validateCheckbox(trimmed, question, otherText, cfg);
case 'dropdown':
return validateDropdown(trimmed, question);
default:
return null;
}
}
function validateText(value: string, question: QuestionSpec, cfg: NonNullable<QuestionSpec['config']>): string | null {
const maxLen = question.maxLength;
if (cfg.minLength !== undefined && value.length < cfg.minLength) {
return `Must be at least ${cfg.minLength} characters`;
}
if (maxLen !== undefined && value.length > maxLen) {
return `Must be at most ${maxLen} characters`;
}
if (cfg.minWords !== undefined && wordCount(value) < cfg.minWords) {
return `Must be at least ${cfg.minWords} words`;
}
if (cfg.maxWords !== undefined && wordCount(value) > cfg.maxWords) {
return `Must be at most ${cfg.maxWords} words`;
}
if (cfg.pattern) {
try {
if (!new RegExp(cfg.pattern).test(value)) {
return cfg.patternError ?? 'Answer does not match the required format';
}
} catch {
// Invalid regex in config — skip the check rather than blocking the user
}
}
return null;
}
function validateEmail(value: string, cfg: NonNullable<QuestionSpec['config']>): string | null {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address';
}
if (cfg.allowedDomains && cfg.allowedDomains.length > 0) {
const domain = value.split('@')[1]?.toLowerCase();
const allowed = cfg.allowedDomains.map((d) => d.toLowerCase());
if (!allowed.includes(domain)) {
return `Email must be from: ${cfg.allowedDomains.join(', ')}`;
}
}
return null;
}
function validateNumber(value: string, cfg: NonNullable<QuestionSpec['config']>): string | null {
const n = Number(value);
if (!Number.isFinite(n)) {
return 'Please enter a valid number';
}
if (cfg.integerOnly && !Number.isInteger(n)) {
return 'Please enter a whole number';
}
if (cfg.min !== undefined && n < cfg.min) {
return `Must be at least ${cfg.min}`;
}
if (cfg.max !== undefined && n > cfg.max) {
return `Must be at most ${cfg.max}`;
}
if (cfg.step !== undefined && cfg.step > 0) {
const base = cfg.min ?? 0;
const remainder = Math.abs((n - base) % cfg.step);
if (remainder > 1e-9 && Math.abs(remainder - cfg.step) > 1e-9) {
return `Must be a multiple of ${cfg.step}`;
}
}
return null;
}
function validateRating(value: string, cfg: NonNullable<QuestionSpec['config']>): string | null {
const n = Number(value);
const scaleMax = cfg.scaleMax ?? 5;
if (!Number.isInteger(n) || n < 1 || n > scaleMax) {
return 'Please select a rating';
}
return null;
}
function validateNps(value: string): string | null {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > 10) {
return 'Please select a score from 0 to 10';
}
return 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 (!validValues.has(value)) {
return 'Please select a valid option';
}
if (value === 'Other' && question.hasOther && (!otherText || otherText.trim() === '')) {
return 'Please specify your answer';
}
return null;
}
function validateCheckbox(
value: string,
question: QuestionSpec,
otherText: string | undefined,
cfg: NonNullable<QuestionSpec['config']>,
): string | null {
const selected = value
.split(',')
.map((v) => v.trim())
.filter(Boolean);
// A value of "," survives the top-level required check (trim keeps commas)
// while representing zero real selections — re-check the parsed count.
if (selected.length === 0) {
return question.required ? 'This question is required' : null;
}
const validValues = new Set((question.options ?? []).map((o) => o.value));
if (question.hasOther) validValues.add('Other');
for (const v of selected) {
if (!validValues.has(v)) {
return `Invalid selection: ${v}`;
}
}
if (cfg.minSelections !== undefined && selected.length < cfg.minSelections) {
return `Please select at least ${cfg.minSelections}`;
}
if (cfg.maxSelections !== undefined && selected.length > cfg.maxSelections) {
return `Please select at most ${cfg.maxSelections}`;
}
if (selected.includes('Other') && question.hasOther && (!otherText || otherText.trim() === '')) {
return "Please specify your 'Other' answer";
}
return null;
}
function validateDropdown(value: string, question: QuestionSpec): string | null {
const validValues = new Set((question.options ?? []).map((o) => o.value));
if (!validValues.has(value)) {
return 'Please select a valid option';
}
return null;
}
@@ -0,0 +1,361 @@
/**
* Typed WebSocket protocol shared by the frontend WS client and the backend DO
* WS handler — a change here changes both sides of the wire at once.
*/
export interface SurveyRow {
id: string;
title: string;
description: string | null;
slug: string | null;
status: string;
welcome_title: string | null;
welcome_description: string | null;
thank_you_title: string | null;
thank_you_description: string | null;
closes_at: string | null;
max_responses: number | null;
randomize_questions: number;
randomize_options: number;
password_hash: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
}
export interface SectionRow {
id: string;
survey_id: string;
title: string;
description: string | null;
sort_order: number;
}
export interface QuestionRow {
id: string;
survey_id: string;
section_id: string;
text: string;
description: string | null;
type: string;
options: string | null;
required: number;
has_other: number;
other_prompt: string | null;
max_length: number | null;
placeholder: string | null;
sort_order: number;
conditional: string | null;
config: string | null;
}
export interface UpdateSurveyInput {
title?: string;
description?: string;
slug?: string;
welcome_title?: string;
welcome_description?: string;
thank_you_title?: string;
thank_you_description?: string;
closes_at?: string | null;
max_responses?: number | null;
randomize_questions?: boolean;
randomize_options?: boolean;
password?: string | null;
}
export interface CreateSectionInput {
title: string;
description?: string;
}
export interface UpdateSectionInput {
title?: string;
description?: string;
}
export interface CreateQuestionInput {
text: string;
description?: string;
type: string;
options?: Array<{ label: string; value: string }>;
required?: boolean;
has_other?: boolean;
other_prompt?: string;
max_length?: number;
placeholder?: string;
conditional?: { showIf: { questionId: string; condition: string; value?: string; values?: string[] } };
config?: Record<string, unknown>;
}
/** 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 {
section_id?: string;
text?: string;
description?: string;
type?: string;
options?: Array<{ label: string; value: string }>;
required?: boolean;
has_other?: boolean;
other_prompt?: string;
max_length?: number;
placeholder?: string;
conditional?: { showIf: { questionId: string; condition: string; value?: string; values?: string[] } } | null;
config?: Record<string, unknown> | null;
}
export interface ReorderItem {
id: string;
sort_order: number;
}
export interface AnswerInput {
questionId: string;
value: string;
otherText?: string;
/** Client-measured ms spent on this question before committing. */
answerMs?: number;
}
export interface SurveyWithDetailsPayload {
survey: SurveyRow;
sections: SectionRow[];
questions: QuestionRow[];
}
export interface PublicSurveyPayload {
survey: Omit<SurveyRow, 'password_hash'> | Partial<SurveyRow>;
sections: SectionRow[];
questions: QuestionRow[];
requiresPassword?: boolean;
}
export interface AggregatedResult {
questionId: string;
answers: Array<{ value: string; otherText: string | null; count: number }>;
}
export interface ResultsPayload {
respondentCounts: { total: number; completed: number };
results: AggregatedResult[];
}
export interface LiveResultsPayload extends ResultsPayload {
liveCounts: { activeViewers: number; activeRespondents: number };
}
export interface TimelineDataPoint {
period: string;
started: number;
completed: number;
}
export interface DropoffDataPoint {
questionId: string;
questionText: string;
respondentsReached: number;
respondentsAnswered: number;
dropoffRate: number;
}
export interface CompletionTimeBucket {
label: string;
minSeconds: number;
maxSeconds: number | null;
count: number;
}
export interface CompletionTimesPayload {
count: number;
mean: number | null;
median: number | null;
p25: number | null;
p75: number | null;
min: number | null;
max: number | null;
buckets: CompletionTimeBucket[];
}
export interface QuestionTimingEntry {
questionId: string;
questionText: string;
sampleSize: number;
meanMs: number | null;
medianMs: number | null;
p5Ms: number | null;
p25Ms: number | null;
p75Ms: number | null;
p95Ms: number | null;
minMs: number | null;
maxMs: number | null;
}
export interface SlowAnalyticsPayload {
timeline: TimelineDataPoint[];
dropoff: DropoffDataPoint[];
completionTimes: CompletionTimesPayload;
questionTimings: QuestionTimingEntry[];
}
export interface RespondentSummary {
id: string;
createdAt: string;
completedAt: string | null;
answerCount: number;
}
export interface RespondentDetailPayload {
id: string;
createdAt: string;
completedAt: string | null;
answers: Array<{
questionId: string;
questionText: string;
questionType: string;
value: string;
otherText: string | null;
}>;
}
export interface ResumePayload {
answers: Record<string, { value: string; otherText?: string }>;
nextQuestionIndex: number;
isComplete: boolean;
respondentId?: string;
isNewRespondent?: boolean;
}
export interface SearchInput {
query: string;
questionId?: string;
offset?: number;
limit?: number;
}
export interface SearchResultsPayload {
results: Array<{
respondentId: string;
questionId: string;
questionText: string;
answer: string;
}>;
total: number;
offset: number;
limit: number;
}
export interface SurveyDefinitionPayload {
version: number;
title: string;
description?: string | null;
welcomeTitle?: string | null;
welcomeDescription?: string | null;
thankYouTitle?: string | null;
thankYouDescription?: string | null;
sections: Array<{
title: string;
description?: string | null;
questions?: Array<{
text: string;
description?: string | null;
type: string;
options?: Array<{ label: string; value: string }> | null;
required?: boolean;
hasOther?: boolean;
otherPrompt?: string | null;
maxLength?: number | null;
placeholder?: string | null;
config?: Record<string, unknown> | null;
}>;
}>;
}
export interface WsOperations {
// Read-only over WS — survey mutations stay on HTTP so the D1 catalog stays in sync.
'get-survey': { request: Record<string, never>; response: SurveyWithDetailsPayload };
'export-definition': { request: Record<string, never>; response: SurveyDefinitionPayload };
'create-section': { request: CreateSectionInput; response: SectionRow };
'update-section': { request: { id: string } & UpdateSectionInput; response: SectionRow };
'delete-section': { request: { id: string }; response: Record<string, never> };
'reorder-sections': { request: { items: ReorderItem[] }; response: Record<string, never> };
'create-question': {
request: { sectionId: string } & CreateQuestionInput;
response: QuestionRow;
};
'update-question': { request: { id: string } & UpdateQuestionInput; response: QuestionRow };
'delete-question': { request: { id: string }; response: Record<string, never> };
'reorder-questions': {
request: { sectionId: string; items: ReorderItem[] };
response: Record<string, never>;
};
'get-results': { request: Record<string, never>; response: ResultsPayload };
'get-live-results': { request: Record<string, never>; response: LiveResultsPayload };
'get-timeline': { request: { granularity: 'minute' | 'hour' | 'day' }; response: TimelineDataPoint[] };
'get-completion-times': { request: Record<string, never>; response: CompletionTimesPayload };
'get-question-timings': { request: Record<string, never>; response: QuestionTimingEntry[] };
'get-dropoff': { request: Record<string, never>; response: DropoffDataPoint[] };
'list-respondents': {
request: { offset?: number; limit?: number };
response: { respondents: RespondentSummary[]; total: number };
};
'get-respondent': { request: { respondentId: string }; response: RespondentDetailPayload };
'delete-respondent': { request: { respondentId: string }; response: Record<string, never> };
'search-answers': { request: SearchInput; response: SearchResultsPayload };
'get-public-survey': { request: Record<string, never>; response: PublicSurveyPayload };
resume: { request: Record<string, never>; response: ResumePayload };
'submit-answers': { request: { answers: AnswerInput[] }; response: Record<string, never> };
complete: { request: Record<string, never>; response: Record<string, never> };
}
export interface WsPushEvents {
counts: { activeViewers: number; activeRespondents: number };
stats: { total: number; completed: number; completionRate: number };
results: ResultsPayload;
analytics: SlowAnalyticsPayload;
}
/** Client → Server */
export type WsRequestMessage = {
[K in keyof WsOperations]: {
type: 'request';
requestId: string;
op: K;
data: WsOperations[K]['request'];
};
}[keyof WsOperations];
/** Server → Client (response to a request) */
export type WsResponseMessage =
| {
[K in keyof WsOperations]: {
type: 'response';
requestId: string;
op: K;
data: WsOperations[K]['response'];
};
}[keyof WsOperations]
| {
type: 'response';
requestId: string;
op: keyof WsOperations;
error: string;
};
/** Server → Client (unsolicited push) */
export type WsPushMessage = {
[K in keyof WsPushEvents]: {
type: 'push';
event: K;
data: WsPushEvents[K];
};
}[keyof WsPushEvents];
export type WsServerMessage = WsResponseMessage | WsPushMessage;
export type WsClientMessage = WsRequestMessage;
+12
View File
@@ -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 {};
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FUTO Surveys</title>
<meta name="theme-color" content="currentColor" />
<meta name="darkreader-lock" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="Create and share surveys" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-light text-dark dark">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,332 @@
import { describe, it, expect } from 'vitest';
import { validateAnswer, type QuestionSpec } from '$shared/answer-validation';
function q(overrides: Partial<QuestionSpec> & { type: string }): QuestionSpec {
return { required: true, ...overrides };
}
// ═══════════════════════════════════════════════════════════════════════════
// Required check (all types)
// ═══════════════════════════════════════════════════════════════════════════
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');
});
it('accepts empty value on optional question', () => {
expect(validateAnswer(q({ type: 'text', required: false }), '')).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Text
// ═══════════════════════════════════════════════════════════════════════════
describe('text validation', () => {
it('accepts valid text', () => {
expect(validateAnswer(q({ type: 'text' }), 'Hello')).toBeNull();
});
it('rejects text below minLength', () => {
expect(validateAnswer(q({ type: 'text', config: { minLength: 5 } }), 'Hi')).toBe('Must be at least 5 characters');
});
it('accepts text at minLength', () => {
expect(validateAnswer(q({ type: 'text', config: { minLength: 5 } }), 'Hello')).toBeNull();
});
it('rejects text above maxLength', () => {
expect(validateAnswer(q({ type: 'text', maxLength: 3 }), 'Hello')).toBe('Must be at most 3 characters');
});
it('rejects text below minWords', () => {
expect(validateAnswer(q({ type: 'textarea', config: { minWords: 3 } }), 'Just two')).toBe(
'Must be at least 3 words',
);
});
it('accepts text at minWords', () => {
expect(validateAnswer(q({ type: 'textarea', config: { minWords: 3 } }), 'Three words here')).toBeNull();
});
it('rejects text above maxWords', () => {
expect(validateAnswer(q({ type: 'textarea', config: { maxWords: 2 } }), 'One two three')).toBe(
'Must be at most 2 words',
);
});
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(
'Answer does not match the required format',
);
});
it('uses custom pattern error message', () => {
const spec = q({
type: 'text',
config: { pattern: '^#', patternError: 'Must start with #' },
});
expect(validateAnswer(spec, 'hello')).toBe('Must start with #');
expect(validateAnswer(spec, '#hello')).toBeNull();
});
it('skips invalid regex gracefully', () => {
expect(validateAnswer(q({ type: 'text', config: { pattern: '[invalid' } }), 'anything')).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Email
// ═══════════════════════════════════════════════════════════════════════════
describe('email validation', () => {
it('accepts valid email', () => {
expect(validateAnswer(q({ type: 'email' }), 'user@example.com')).toBeNull();
});
it('rejects missing @', () => {
expect(validateAnswer(q({ type: 'email' }), 'userexample.com')).toBe('Please enter a valid email address');
});
it('rejects missing domain', () => {
expect(validateAnswer(q({ type: 'email' }), 'user@')).toBe('Please enter a valid email address');
});
it('rejects spaces', () => {
expect(validateAnswer(q({ type: 'email' }), 'user @example.com')).toBe('Please enter a valid email address');
});
it('enforces allowed domains', () => {
const spec = q({ type: 'email', config: { allowedDomains: ['company.com', 'corp.net'] } });
expect(validateAnswer(spec, 'alice@company.com')).toBeNull();
expect(validateAnswer(spec, 'bob@corp.net')).toBeNull();
expect(validateAnswer(spec, 'eve@gmail.com')).toBe('Email must be from: company.com, corp.net');
});
it('allowed domains check is case-insensitive', () => {
const spec = q({ type: 'email', config: { allowedDomains: ['Company.COM'] } });
expect(validateAnswer(spec, 'alice@company.com')).toBeNull();
});
it('skips domain check when allowedDomains is empty', () => {
expect(validateAnswer(q({ type: 'email', config: { allowedDomains: [] } }), 'a@b.com')).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Number
// ═══════════════════════════════════════════════════════════════════════════
describe('number validation', () => {
it('accepts valid number', () => {
expect(validateAnswer(q({ type: 'number' }), '42')).toBeNull();
expect(validateAnswer(q({ type: 'number' }), '-3.14')).toBeNull();
});
it('rejects non-numeric', () => {
expect(validateAnswer(q({ type: 'number' }), 'abc')).toBe('Please enter a valid number');
});
it('rejects NaN', () => {
expect(validateAnswer(q({ type: 'number' }), 'NaN')).toBe('Please enter a valid number');
});
it('enforces min', () => {
expect(validateAnswer(q({ type: 'number', config: { min: 0 } }), '-1')).toBe('Must be at least 0');
});
it('enforces max', () => {
expect(validateAnswer(q({ type: 'number', config: { max: 100 } }), '101')).toBe('Must be at most 100');
});
it('enforces integerOnly', () => {
expect(validateAnswer(q({ type: 'number', config: { integerOnly: true } }), '3.5')).toBe(
'Please enter a whole number',
);
expect(validateAnswer(q({ type: 'number', config: { integerOnly: true } }), '3')).toBeNull();
});
it('enforces step', () => {
const spec = q({ type: 'number', config: { step: 5, min: 0 } });
expect(validateAnswer(spec, '10')).toBeNull();
expect(validateAnswer(spec, '15')).toBeNull();
expect(validateAnswer(spec, '7')).toBe('Must be a multiple of 5');
});
it('step works with non-zero min', () => {
const spec = q({ type: 'number', config: { step: 3, min: 1 } });
expect(validateAnswer(spec, '1')).toBeNull(); // 1 + 0*3
expect(validateAnswer(spec, '4')).toBeNull(); // 1 + 1*3
expect(validateAnswer(spec, '3')).toBe('Must be a multiple of 3');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Rating
// ═══════════════════════════════════════════════════════════════════════════
describe('rating validation', () => {
it('accepts valid rating', () => {
expect(validateAnswer(q({ type: 'rating' }), '3')).toBeNull();
expect(validateAnswer(q({ type: 'rating', config: { scaleMax: 10 } }), '10')).toBeNull();
});
it('rejects 0', () => {
expect(validateAnswer(q({ type: 'rating' }), '0')).toBe('Please select a rating');
});
it('rejects above scaleMax', () => {
expect(validateAnswer(q({ type: 'rating' }), '6')).toBe('Please select a rating');
expect(validateAnswer(q({ type: 'rating', config: { scaleMax: 10 } }), '11')).toBe('Please select a rating');
});
it('rejects non-integer', () => {
expect(validateAnswer(q({ type: 'rating' }), '3.5')).toBe('Please select a rating');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// NPS
// ═══════════════════════════════════════════════════════════════════════════
describe('nps validation', () => {
it('accepts 0-10', () => {
for (let i = 0; i <= 10; i++) {
expect(validateAnswer(q({ type: 'nps' }), String(i))).toBeNull();
}
});
it('rejects 11', () => {
expect(validateAnswer(q({ type: 'nps' }), '11')).toBe('Please select a score from 0 to 10');
});
it('rejects -1', () => {
expect(validateAnswer(q({ type: 'nps' }), '-1')).toBe('Please select a score from 0 to 10');
});
it('rejects decimal', () => {
expect(validateAnswer(q({ type: 'nps' }), '5.5')).toBe('Please select a score from 0 to 10');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Likert
// ═══════════════════════════════════════════════════════════════════════════
describe('likert validation', () => {
it('accepts all 5 valid labels', () => {
for (const label of ['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree']) {
expect(validateAnswer(q({ type: 'likert' }), label)).toBeNull();
}
});
it('rejects arbitrary string', () => {
expect(validateAnswer(q({ type: 'likert' }), 'Maybe')).toBe('Please select a valid option');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Radio
// ═══════════════════════════════════════════════════════════════════════════
describe('radio validation', () => {
const opts = [{ value: 'A' }, { value: 'B' }, { value: 'C' }];
it('accepts valid option', () => {
expect(validateAnswer(q({ type: 'radio', options: opts }), 'B')).toBeNull();
});
it('rejects value not in options', () => {
expect(validateAnswer(q({ type: 'radio', options: opts }), 'Z')).toBe('Please select a valid option');
});
it('accepts Other when hasOther', () => {
expect(validateAnswer(q({ type: 'radio', options: opts, hasOther: true }), 'Other', 'My answer')).toBeNull();
});
it('rejects Other without otherText', () => {
expect(validateAnswer(q({ type: 'radio', options: opts, hasOther: true }), 'Other', '')).toBe(
'Please specify your answer',
);
expect(validateAnswer(q({ type: 'radio', options: opts, hasOther: true }), 'Other')).toBe(
'Please specify your answer',
);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Checkbox
// ═══════════════════════════════════════════════════════════════════════════
describe('checkbox validation', () => {
const opts = [{ value: 'X' }, { value: 'Y' }, { value: 'Z' }];
it('accepts valid single selection', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts }), 'X')).toBeNull();
});
it('accepts valid multi selection', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts }), 'X,Z')).toBeNull();
});
it('rejects invalid option', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts }), 'X,INVALID')).toBe('Invalid selection: INVALID');
});
it('enforces minSelections', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts, config: { minSelections: 2 } }), 'X')).toBe(
'Please select at least 2',
);
});
it('enforces maxSelections', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts, config: { maxSelections: 1 } }), 'X,Y')).toBe(
'Please select at most 1',
);
});
it('accepts Other with text when hasOther', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts, hasOther: true }), 'X,Other', 'Custom')).toBeNull();
});
it('rejects Other without text when hasOther', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts, hasOther: true }), 'X,Other', '')).toBe(
"Please specify your 'Other' answer",
);
});
it('rejects comma-only value on required checkbox (no real selections)', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts, required: true }), ',')).toBe(
'This question is required',
);
expect(validateAnswer(q({ type: 'checkbox', options: opts, required: true }), ',,,')).toBe(
'This question is required',
);
expect(validateAnswer(q({ type: 'checkbox', options: opts, required: true }), ', , ,')).toBe(
'This question is required',
);
});
it('accepts comma-only value on optional checkbox', () => {
expect(validateAnswer(q({ type: 'checkbox', options: opts, required: false }), ',')).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Dropdown
// ═══════════════════════════════════════════════════════════════════════════
describe('dropdown validation', () => {
const opts = [{ value: 'opt1' }, { value: 'opt2' }];
it('accepts valid option', () => {
expect(validateAnswer(q({ type: 'dropdown', options: opts }), 'opt1')).toBeNull();
});
it('rejects value not in options', () => {
expect(validateAnswer(q({ type: 'dropdown', options: opts }), 'opt3')).toBe('Please select a valid option');
});
});
@@ -0,0 +1,25 @@
import { request } from './request';
export interface AuditEntry {
id: string;
user_sub: string;
user_email: string;
action: string;
resource_type: string;
resource_id: string | null;
details: string | null;
ip_address: string | null;
created_at: string;
}
export async function getAuditLog(offset = 0, limit = 50): Promise<{ entries: AuditEntry[]; total: number }> {
return request(`/api/audit-log?offset=${offset}&limit=${limit}`);
}
export async function getSurveyAuditLog(
surveyId: string,
offset = 0,
limit = 50,
): Promise<{ entries: AuditEntry[]; total: number }> {
return request(`/api/audit-log/survey/${surveyId}?offset=${offset}&limit=${limit}`);
}
@@ -0,0 +1,65 @@
interface AuthUser {
sub: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
}
interface AuthState {
authenticated: boolean;
user?: AuthUser;
needsSetup?: boolean;
needsSetupToken?: boolean;
oidcEnabled?: boolean;
passwordEnabled?: boolean;
}
export async function getMe(): Promise<AuthState> {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (!res.ok) return { authenticated: false };
return res.json() as Promise<AuthState>;
} catch {
return { authenticated: false };
}
}
export async function setup(password: string, setupToken?: string): Promise<void> {
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(setupToken ? { 'X-Setup-Token': setupToken } : {}),
},
body: JSON.stringify({ password }),
credentials: 'include',
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Setup failed' }));
throw new Error((body as { error?: string }).error ?? 'Setup failed');
}
}
export async function passwordLogin(password: string): Promise<void> {
const res = await fetch('/api/auth/password-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
credentials: 'include',
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Login failed' }));
throw new Error((body as { error?: string }).error ?? 'Login failed');
}
}
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();
}
export async function logout(): Promise<void> {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/';
}
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createApiClient } from './client';
describe('createApiClient', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204, json: () => Promise.resolve({}) }));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('creates isolated instances per slug', () => {
const client1 = createApiClient('survey-a');
const client2 = createApiClient('survey-b');
client1.bufferAnswer({ questionId: 'q1', value: 'yes' });
expect(client1.getBufferSize()).toBe(1);
expect(client2.getBufferSize()).toBe(0);
client1.destroy();
client2.destroy();
});
it('buffers answers and auto-flushes at threshold', async () => {
const client = createApiClient('test-survey');
client.bufferAnswer({ questionId: 'q1', value: 'a' });
client.bufferAnswer({ questionId: 'q2', value: 'b' });
client.bufferAnswer({ questionId: 'q3', value: 'c' });
expect(client.getBufferSize()).toBe(3);
client.bufferAnswer({ questionId: 'q4', value: 'd' });
// Flush is async, let microtasks run
await vi.runAllTimersAsync();
expect(fetch).toHaveBeenCalledWith('/api/s/test-survey/answers/batch', expect.objectContaining({ method: 'POST' }));
expect(client.getBufferSize()).toBe(0);
client.destroy();
});
it('auto-flushes on inactivity', async () => {
const client = createApiClient('test-survey');
client.bufferAnswer({ questionId: 'q1', value: 'a' });
expect(client.getBufferSize()).toBe(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(fetch).toHaveBeenCalledWith('/api/s/test-survey/answers/batch', expect.objectContaining({ method: 'POST' }));
client.destroy();
});
it('re-adds to buffer on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
const onError = vi.fn();
const client = createApiClient('test-survey');
client.onSaveError(onError);
client.bufferAnswer({ questionId: 'q1', value: 'a' });
const firstFlush = client.flushBuffer();
await vi.runAllTimersAsync();
const firstResult = await firstFlush;
expect(firstResult).toBe(false);
expect(client.getBufferSize()).toBe(1);
expect(onError).not.toHaveBeenCalled();
const secondFlush = client.flushBuffer();
await vi.runAllTimersAsync();
const secondResult = await secondFlush;
expect(secondResult).toBe(false);
expect(client.getBufferSize()).toBe(1);
expect(onError).toHaveBeenCalled();
client.destroy();
});
it('clears the toast after a successful flush following failures', async () => {
let succeed = false;
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: succeed, status: succeed ? 204 : 500 })),
);
const onError = vi.fn();
const onSuccess = vi.fn();
const client = createApiClient('test-survey');
client.onSaveError(onError);
client.onSaveSuccess(onSuccess);
client.bufferAnswer({ questionId: 'q1', value: 'a' });
// Two consecutive flush failures (each saveBatchHttp exhausts its
// own retries internally before returning false).
const first = client.flushBuffer();
await vi.runAllTimersAsync();
await first;
const second = client.flushBuffer();
await vi.runAllTimersAsync();
await second;
expect(onError).toHaveBeenCalled();
succeed = true;
const third = client.flushBuffer();
await vi.runAllTimersAsync();
const ok = await third;
expect(ok).toBe(true);
expect(onSuccess).toHaveBeenCalled();
client.destroy();
});
it('preserves newer entries when failed batch is re-added', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
const client = createApiClient('test-survey');
client.bufferAnswer({ questionId: 'q1', value: 'old' });
// Start flush — this clears the buffer, then tries to send
const flushPromise = client.flushBuffer();
client.bufferAnswer({ questionId: 'q1', value: 'new' });
// Let backoff timers resolve so saveBatch finishes
await vi.runAllTimersAsync();
await flushPromise;
// The buffer should have 1 entry: the "new" value, not overwritten by the failed "old"
expect(client.getBufferSize()).toBe(1);
client.destroy();
});
it('uses correct slug in URLs', async () => {
const client = createApiClient('my-custom-slug');
await client.fetchResume();
expect(fetch).toHaveBeenCalledWith(
'/api/s/my-custom-slug/resume',
expect.objectContaining({ credentials: 'same-origin' }),
);
client.destroy();
});
});
@@ -0,0 +1,252 @@
import type { SurveyAnswer } from '../types';
import type { SurveyWsClient } from './survey-ws';
import { BATCH_ANSWER_LIMIT } from '$shared/ws-protocol';
interface PendingSave {
questionId: string;
value: string;
otherText?: string;
/** Client-measured milliseconds spent on this question before committing. */
answerMs?: number;
}
const BACKOFF_DELAYS = [1000, 2000, 4000];
const INACTIVITY_MS = 10_000;
const FLUSH_THRESHOLD = 4;
const FAILURES_BEFORE_TOAST = 2;
const WS_RECONNECT_WAIT_MS = 2000;
/**
* Transport for data ops (submit-answers, complete, resume): 'ws' on Cloudflare
* Workers + DO, 'http' with cookie auth in Node.js self-hosted mode. Committed
* on the first resume and never switched — a per-request HTTP fallback would
* re-run cookie auth on every submission and cut server capacity.
*/
type Mode = 'ws' | 'http' | 'unknown';
export function createApiClient(slug: string) {
const base = `/api/s/${slug}`;
const answerBuffer: Map<string, PendingSave> = new Map();
let inactivityTimer: ReturnType<typeof setTimeout> | null = null;
let unflushedCount = 0;
let consecutiveFailures = 0;
let onSaveErrorCallback: ((message: string) => void) | null = null;
let onSaveSuccessCallback: (() => void) | null = null;
let wsClient: SurveyWsClient | null = null;
let mode: Mode = 'unknown';
function setWsClient(client: SurveyWsClient | null) {
wsClient = client;
}
function resetInactivityTimer() {
if (inactivityTimer !== null) {
clearTimeout(inactivityTimer);
}
inactivityTimer = setTimeout(() => {
flushBuffer();
}, INACTIVITY_MS);
}
function bufferAnswer(data: PendingSave): void {
// Only NEW questions advance the counter — otherwise every keystroke in a
// 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++;
resetInactivityTimer();
if (unflushedCount >= FLUSH_THRESHOLD) {
flushBuffer();
}
}
async function saveBatch(answers: PendingSave[]): Promise<boolean> {
if (mode === 'ws') {
// No HTTP fallback here (see Mode) — wait briefly for the auto-reconnect
// instead, so a transient drop doesn't surface as a save failure.
if (!wsClient?.connected) {
const deadline = Date.now() + WS_RECONNECT_WAIT_MS;
while (!wsClient?.connected && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
}
if (!wsClient?.connected) return false;
}
try {
await wsClient.request('submit-answers', { answers });
return true;
} catch {
return false;
}
}
return saveBatchHttp(answers);
}
async function saveBatchHttp(answers: PendingSave[]): Promise<boolean> {
for (let attempt = 0; attempt <= BACKOFF_DELAYS.length; attempt++) {
try {
const res = await fetch(`${base}/answers/batch`, {
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;
} catch {
// network error, retry
}
if (attempt < BACKOFF_DELAYS.length) {
await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[attempt]));
}
}
return false;
}
async function flushBuffer(): Promise<boolean> {
if (answerBuffer.size === 0) return true;
if (inactivityTimer !== null) {
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
const all = [...answerBuffer.values()];
answerBuffer.clear();
// Chunk to the server's cap: an oversized submit-answers request is
// rejected wholesale (400), dropping every buffered answer at once.
const failed: PendingSave[] = [];
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);
}
const success = failed.length === 0;
if (success) {
unflushedCount = 0;
consecutiveFailures = 0;
onSaveSuccessCallback?.();
} else {
for (const item of failed) {
if (!answerBuffer.has(item.questionId)) {
answerBuffer.set(item.questionId, item);
}
}
consecutiveFailures++;
// Stay silent on transient blips — buffered answers retry on the next
// flush trigger; only a persistent failure is worth alarming the user.
if (consecutiveFailures >= FAILURES_BEFORE_TOAST) {
onSaveErrorCallback?.('Failed to save answers. Your responses will be retried automatically.');
}
}
return success;
}
function flushBufferSync(): void {
// 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 (inactivityTimer !== null) {
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
unflushedCount = 0;
const all = [...answerBuffer.values()];
answerBuffer.clear();
// Same per-batch cap as flushBuffer — send one beacon per chunk so a large
// pending buffer isn't rejected as a single oversized request on unload.
for (let i = 0; i < all.length; i += BATCH_ANSWER_LIMIT) {
const chunk = all.slice(i, i + BATCH_ANSWER_LIMIT);
const blob = new Blob([JSON.stringify({ answers: chunk })], { type: 'application/json' });
navigator.sendBeacon(`${base}/answers/batch`, blob);
}
}
async function fetchResume(): Promise<{
answers?: Record<string, SurveyAnswer>;
nextQuestionIndex?: number;
isComplete?: boolean;
}> {
if (wsClient) {
// Wait briefly for WS to finish connecting (auto-reconnect may still be in progress)
for (let i = 0; i < 20 && !wsClient.connected; i++) {
await new Promise((r) => setTimeout(r, 100));
}
if (wsClient.connected) {
try {
const result = (await wsClient.request('resume', {})) as {
answers?: Record<string, SurveyAnswer>;
nextQuestionIndex?: number;
isComplete?: boolean;
};
mode = 'ws';
return result;
} catch {
// WS resume failed — server likely doesn't support command ops (Node.js mode)
}
}
}
mode = 'http';
const res = await fetch(`${base}/resume`, { credentials: 'same-origin' });
if (!res.ok) {
throw new Error(`Failed to load survey (${res.status})`);
}
return res.json();
}
async function postComplete(): Promise<void> {
if (mode === 'ws') {
if (!wsClient?.connected) {
throw new Error('Connection lost — please try again in a moment');
}
await wsClient.request('complete', {});
return;
}
const res = await fetch(`${base}/complete`, {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
throw new Error(`Failed to submit survey (${res.status})`);
}
}
function onSaveError(cb: (message: string) => void): void {
onSaveErrorCallback = cb;
}
function onSaveSuccess(cb: () => void): void {
onSaveSuccessCallback = cb;
}
function getBufferSize(): number {
return answerBuffer.size;
}
function destroy(): void {
if (inactivityTimer !== null) {
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
}
return {
bufferAnswer,
flushBuffer,
flushBufferSync,
fetchResume,
postComplete,
onSaveError,
onSaveSuccess,
getBufferSize,
destroy,
setWsClient,
};
}
@@ -0,0 +1,23 @@
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
if (res.status === 401) {
// Reload to trigger the layout auth check, which shows the login screen
window.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;
return res.json() as Promise<T>;
}
@@ -0,0 +1,153 @@
import { request } from './request';
import { surveyFromApi, questionsFromApi, sectionsFromApiRaw } from '../engines/builder-engine.svelte';
import type { Survey, SurveyWithDetails } from '../types';
import { getWsClientById } from './survey-ws';
export async function listSurveys(includeArchived = false): Promise<Survey[]> {
const url = includeArchived ? '/api/surveys?archived=true' : '/api/surveys';
const data = await request<{ surveys: Array<Record<string, unknown>>; total: number }>(url);
return data.surveys.map(surveyFromApi);
}
export async function listSurveysPaginated(opts: {
includeArchived?: boolean;
search?: string;
offset?: number;
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));
const data = await request<{ surveys: Array<Record<string, unknown>>; total: number }>(`/api/surveys?${params}`);
return { surveys: data.surveys.map(surveyFromApi), total: data.total };
}
export async function getSurvey(id: string): Promise<SurveyWithDetails> {
const ws = getWsClientById(id);
if (ws?.connected) {
const data = (await ws.request('get-survey', {})) as unknown as {
survey: Record<string, unknown>;
sections: Array<Record<string, unknown>>;
questions: Array<Record<string, unknown>>;
};
return {
survey: surveyFromApi(data.survey),
sections: sectionsFromApiRaw(data.sections),
questions: questionsFromApi(data.questions),
};
}
const data = await request<{
survey: Record<string, unknown>;
sections: Array<Record<string, unknown>>;
questions: Array<Record<string, unknown>>;
}>(`/api/surveys/${id}`);
return {
survey: surveyFromApi(data.survey),
sections: sectionsFromApiRaw(data.sections),
questions: questionsFromApi(data.questions),
};
}
export async function createSurvey(input: { title: string; description?: string }): Promise<Survey> {
const data = await request<Record<string, unknown>>('/api/surveys', {
method: 'POST',
body: JSON.stringify(input),
});
return surveyFromApi(data);
}
export async function updateSurvey(
id: string,
input: {
title?: string | null;
description?: string | null;
slug?: string | null;
welcome_title?: string | null;
welcome_description?: string | null;
thank_you_title?: string | null;
thank_you_description?: string | null;
closes_at?: string | null;
max_responses?: number | null;
randomize_questions?: boolean;
randomize_options?: boolean;
password?: string | null;
},
): Promise<Survey> {
const explicitlySetFields = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined));
const data = await request<Record<string, unknown>>(`/api/surveys/${id}`, {
method: 'PUT',
body: JSON.stringify(explicitlySetFields),
});
return surveyFromApi(data);
}
export async function deleteSurvey(id: string): Promise<void> {
await request(`/api/surveys/${id}`, { method: 'DELETE' });
}
export async function publishSurvey(id: string): Promise<Survey> {
const data = await request<Record<string, unknown>>(`/api/surveys/${id}/publish`, {
method: 'PUT',
});
return surveyFromApi(data);
}
export async function unpublishSurvey(id: string): Promise<Survey> {
const data = await request<Record<string, unknown>>(`/api/surveys/${id}/unpublish`, {
method: 'PUT',
});
return surveyFromApi(data);
}
export async function duplicateSurvey(id: string): Promise<SurveyWithDetails> {
const data = await request<{
survey: Record<string, unknown>;
sections: Array<Record<string, unknown>>;
questions: Array<Record<string, unknown>>;
}>(`/api/surveys/${id}/duplicate`, { method: 'POST' });
return {
survey: surveyFromApi(data.survey),
sections: sectionsFromApiRaw(data.sections),
questions: questionsFromApi(data.questions),
};
}
export async function archiveSurvey(id: string): Promise<Survey> {
const data = await request<Record<string, unknown>>(`/api/surveys/${id}/archive`, {
method: 'PUT',
});
return surveyFromApi(data);
}
export async function unarchiveSurvey(id: string): Promise<Survey> {
const data = await request<Record<string, unknown>>(`/api/surveys/${id}/unarchive`, {
method: 'PUT',
});
return surveyFromApi(data);
}
export async function exportSurveyDefinition(id: string): Promise<Record<string, unknown>> {
const ws = getWsClientById(id);
if (ws?.connected) {
return ws.request('export-definition', {}) as unknown as Promise<Record<string, unknown>>;
}
return request<Record<string, unknown>>(`/api/surveys/${id}/definition`);
}
export async function importSurveyDefinition(definition: unknown): Promise<SurveyWithDetails> {
const data = await request<{
survey: Record<string, unknown>;
sections: Array<Record<string, unknown>>;
questions: Array<Record<string, unknown>>;
}>('/api/surveys/import', {
method: 'POST',
body: JSON.stringify(definition),
});
return {
survey: surveyFromApi(data.survey),
sections: sectionsFromApiRaw(data.sections),
questions: questionsFromApi(data.questions),
};
}
@@ -0,0 +1,45 @@
import { request } from './request';
import { surveyFromApi, questionsFromApi, sectionsFromApiRaw } from '../engines/builder-engine.svelte';
import type { SurveyWithDetails } from '../types';
import { getWsClientBySlug } from './survey-ws';
export async function getPublishedSurvey(slug: string): Promise<SurveyWithDetails> {
try {
const ws = getWsClientBySlug(slug);
if (ws?.connected) {
const data = (await ws.request('get-public-survey', {})) as unknown as {
survey: Record<string, unknown>;
sections: Array<Record<string, unknown>>;
questions: Array<Record<string, unknown>>;
requiresPassword?: boolean;
};
return {
survey: surveyFromApi({ ...data.survey, requiresPassword: data.requiresPassword }),
sections: sectionsFromApiRaw(data.sections),
questions: questionsFromApi(data.questions),
};
}
} catch {
// Fall through to HTTP
}
const data = await request<{
survey: Record<string, unknown>;
sections: Array<Record<string, unknown>>;
questions: Array<Record<string, unknown>>;
requiresPassword?: boolean;
}>(`/api/s/${slug}`);
return {
survey: surveyFromApi({ ...data.survey, requiresPassword: data.requiresPassword }),
sections: sectionsFromApiRaw(data.sections),
questions: questionsFromApi(data.questions),
};
}
// Auth stays HTTP — sets password cookie
export async function authenticateSurvey(slug: string, password: string): Promise<void> {
await request(`/api/s/${slug}/auth`, {
method: 'POST',
body: JSON.stringify({ password }),
});
}
@@ -0,0 +1,88 @@
import { request } from './request';
import { questionsFromApi } from '../engines/builder-engine.svelte';
import type { QuestionOption, QuestionType, SurveyQuestion } from '../types';
import { getWsClientById } from './survey-ws';
export async function createQuestion(
surveyId: string,
sectionId: string,
input: {
text: string;
description?: string;
type: QuestionType;
options?: QuestionOption[];
required?: boolean;
has_other?: boolean;
other_prompt?: string;
max_length?: number;
placeholder?: string;
config?: Record<string, unknown>;
conditional?: SurveyQuestion['conditional'];
},
): Promise<SurveyQuestion> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
const data = await ws.request('create-question', { sectionId, ...input });
return questionsFromApi([data as unknown as Record<string, unknown>])[0];
}
const data = await request<Record<string, unknown>>(`/api/surveys/${surveyId}/sections/${sectionId}/questions`, {
method: 'POST',
body: JSON.stringify(input),
});
return questionsFromApi([data])[0];
}
export async function updateQuestion(
surveyId: string,
id: string,
input: {
section_id?: string;
text?: string;
description?: string;
type?: QuestionType;
options?: QuestionOption[];
required?: boolean;
has_other?: boolean;
other_prompt?: string;
max_length?: number;
placeholder?: string;
config?: Record<string, unknown> | null;
conditional?: SurveyQuestion['conditional'] | null;
},
): Promise<SurveyQuestion> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
const data = await ws.request('update-question', { id, ...input });
return questionsFromApi([data as unknown as Record<string, unknown>])[0];
}
const data = await request<Record<string, unknown>>(`/api/surveys/${surveyId}/questions/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
});
return questionsFromApi([data])[0];
}
export async function deleteQuestion(surveyId: string, id: string): Promise<void> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
await ws.request('delete-question', { id });
return;
}
await request(`/api/surveys/${surveyId}/questions/${id}`, { method: 'DELETE' });
}
export async function reorderQuestions(
surveyId: string,
sectionId: string,
items: Array<{ id: string; sort_order: number }>,
): Promise<void> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
await ws.request('reorder-questions', { sectionId, items });
return;
}
await request(`/api/surveys/${surveyId}/sections/${sectionId}/questions/reorder`, {
method: 'PUT',
body: JSON.stringify({ items }),
});
}
@@ -0,0 +1,208 @@
import { request } from './request';
import type {
TimelineDataPoint,
DropoffDataPoint,
CompletionTimesPayload,
QuestionTimingEntry,
RespondentSummary,
RespondentDetail,
SearchResult,
LiveCounts,
} from '../types';
import { getWsClientById, type SurveyWsClient } from './survey-ws';
/**
* Prefer a WebSocket command op, falling back to HTTP when there is no socket
* or the op is rejected: in self-hosted Node mode the WS is presence-only and
* rejects command ops, so without the fallback these calls would just error.
*/
async function wsOrHttp<T>(
surveyId: string,
send: (ws: SurveyWsClient) => Promise<unknown>,
http: () => Promise<T>,
): Promise<T> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
try {
return (await send(ws)) as T;
} catch {
// Fall through to HTTP.
}
}
return http();
}
export async function getSurveyResults(id: string): Promise<{
respondentCounts: { total: number; completed: number };
results: Array<{
questionId: string;
answers: Array<{ value: string; otherText: string | null; count: number }>;
}>;
}> {
return wsOrHttp(
id,
(ws) => ws.request('get-results', {}),
() => request(`/api/surveys/${id}/results`),
);
}
export async function getSurveyTimeline(
id: string,
granularity: 'minute' | 'hour' | 'day' = 'day',
): Promise<TimelineDataPoint[]> {
return wsOrHttp(
id,
(ws) => ws.request('get-timeline', { granularity }),
() => request(`/api/surveys/${id}/results/timeline?granularity=${granularity}`),
);
}
export async function getSurveyDropoff(id: string): Promise<DropoffDataPoint[]> {
return wsOrHttp(
id,
(ws) => ws.request('get-dropoff', {}),
() => request(`/api/surveys/${id}/results/dropoff`),
);
}
export async function getSurveyCompletionTimes(id: string): Promise<CompletionTimesPayload> {
return wsOrHttp(
id,
(ws) => ws.request('get-completion-times', {}),
() => request(`/api/surveys/${id}/results/completion-times`),
);
}
export async function getSurveyQuestionTimings(id: string): Promise<QuestionTimingEntry[]> {
return wsOrHttp(
id,
(ws) => ws.request('get-question-timings', {}),
() => request(`/api/surveys/${id}/results/question-timings`),
);
}
export async function listRespondents(
id: string,
offset = 0,
limit = 20,
): Promise<{ respondents: RespondentSummary[]; total: number }> {
return wsOrHttp(
id,
(ws) => ws.request('list-respondents', { offset, limit }),
() => request(`/api/surveys/${id}/results/respondents?offset=${offset}&limit=${limit}`),
);
}
export async function getRespondent(surveyId: string, respondentId: string): Promise<RespondentDetail> {
return wsOrHttp(
surveyId,
(ws) => ws.request('get-respondent', { respondentId }),
() => request(`/api/surveys/${surveyId}/results/respondents/${respondentId}`),
);
}
export async function searchAnswers(
id: string,
query: string,
questionId?: string,
pagination?: { offset?: number; limit?: number },
): Promise<{ results: SearchResult[]; total: number; offset: number; limit: number }> {
return wsOrHttp(
id,
(ws) =>
ws.request('search-answers', {
query,
questionId,
offset: pagination?.offset,
limit: pagination?.limit,
}),
() => {
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));
return request(`/api/surveys/${id}/results/search?${params}`);
},
);
}
type LiveResults = {
respondentCounts: { total: number; completed: number };
results: Array<{
questionId: string;
answers: Array<{ value: string; otherText: string | null; count: number }>;
}>;
liveCounts: LiveCounts;
};
const liveResultsEtags = new Map<string, string>();
export async function getLiveResults(id: string): Promise<LiveResults | null> {
const ws = getWsClientById(id);
if (ws?.connected) {
try {
return (await ws.request('get-live-results', {})) as LiveResults;
} catch {
// Fall through to HTTP (self-hosted presence-only WS).
}
}
const headers: Record<string, string> = {};
const cachedEtag = liveResultsEtags.get(id);
if (cachedEtag) {
headers['If-None-Match'] = cachedEtag;
}
const res = await fetch(`/api/surveys/${id}/results/live`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...headers },
});
if (res.status === 304) {
return null;
}
if (!res.ok) {
if (res.status === 401) {
window.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})`);
}
const etag = res.headers.get('ETag');
if (etag) {
liveResultsEtags.set(id, etag);
}
return res.json() as Promise<LiveResults>;
}
export async function deleteRespondent(surveyId: string, respondentId: string): Promise<void> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
try {
await ws.request('delete-respondent', { respondentId });
return;
} catch {
// Fall through to HTTP (self-hosted presence-only WS).
}
}
await request(`/api/surveys/${surveyId}/results/respondents/${respondentId}`, {
method: 'DELETE',
});
}
// Export stays HTTP — binary file download
export async function exportResults(id: string, format: 'csv' | 'json'): Promise<void> {
const res = await fetch(`/api/surveys/${id}/results/export?format=${format}`);
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `survey-results.${format}`;
a.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,61 @@
import { request } from './request';
import { sectionsFromApiRaw } from '../engines/builder-engine.svelte';
import type { SurveySection } from '../types';
import { getWsClientById } from './survey-ws';
export async function createSection(
surveyId: string,
input: { title: string; description?: string },
): Promise<SurveySection> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
const data = await ws.request('create-section', input);
return sectionsFromApiRaw([data as unknown as Record<string, unknown>])[0];
}
const data = await request<Record<string, unknown>>(`/api/surveys/${surveyId}/sections`, {
method: 'POST',
body: JSON.stringify(input),
});
return sectionsFromApiRaw([data])[0];
}
export async function updateSection(
surveyId: string,
id: string,
input: { title?: string; description?: string },
): Promise<SurveySection> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
const data = await ws.request('update-section', { id, ...input });
return sectionsFromApiRaw([data as unknown as Record<string, unknown>])[0];
}
const data = await request<Record<string, unknown>>(`/api/surveys/${surveyId}/sections/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
});
return sectionsFromApiRaw([data])[0];
}
export async function deleteSection(surveyId: string, id: string): Promise<void> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
await ws.request('delete-section', { id });
return;
}
await request(`/api/surveys/${surveyId}/sections/${id}`, { method: 'DELETE' });
}
export async function reorderSections(
surveyId: string,
items: Array<{ id: string; sort_order: number }>,
): Promise<void> {
const ws = getWsClientById(surveyId);
if (ws?.connected) {
await ws.request('reorder-sections', { items });
return;
}
await request(`/api/surveys/${surveyId}/sections/reorder`, {
method: 'PUT',
body: JSON.stringify({ items }),
});
}

Some files were not shown because too many files have changed in this diff Show More