mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
chore: remove survey app (#823)
This commit is contained in:
@@ -77,7 +77,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'survey']
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
||||
env:
|
||||
TF_VAR_app_name: ${{ matrix.app_name }}
|
||||
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
|
||||
|
||||
@@ -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', 'survey']
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
||||
env:
|
||||
ENVIRONMENT: ${{ matrix.environment }}
|
||||
TF_VAR_app_name: ${{ matrix.app_name }}
|
||||
|
||||
@@ -75,83 +75,3 @@ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@df46d635b905e618b88fc4c95baa920aeb30b309 # create-workflow-token-action-v3.0.1
|
||||
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@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
|
||||
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
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# === 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)
|
||||
@@ -1,4 +0,0 @@
|
||||
backend/.wrangler
|
||||
test-results
|
||||
playwright-report
|
||||
dogfood-output
|
||||
@@ -1,45 +0,0 @@
|
||||
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
|
||||
# pnpm version is pinned via the root package.json "packageManager" field
|
||||
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 common/ common/
|
||||
RUN pnpm install --frozen-lockfile --filter survey.immich.app...
|
||||
COPY apps/survey.immich.app/ apps/survey.immich.app/
|
||||
RUN cd apps/survey.immich.app && pnpm run build
|
||||
|
||||
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 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/
|
||||
RUN pnpm install --frozen-lockfile --filter survey-backend...
|
||||
COPY apps/survey.immich.app/shared/ apps/survey.immich.app/shared/
|
||||
COPY apps/survey.immich.app/backend/ apps/survey.immich.app/backend/
|
||||
RUN cd apps/survey.immich.app/backend && pnpm run build:node
|
||||
|
||||
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553
|
||||
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"]
|
||||
@@ -1,327 +0,0 @@
|
||||
# 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
|
||||
@@ -1,2 +0,0 @@
|
||||
data/
|
||||
.svelte-kit/
|
||||
@@ -1,125 +0,0 @@
|
||||
-- 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
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "survey-backend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 --banner:js=\"import { createRequire as topLevelCreateRequire } from 'node:module'; const require = topLevelCreateRequire(import.meta.url);\"",
|
||||
"load-test": "tsx scripts/load-test.ts",
|
||||
"test:integration": "vitest run",
|
||||
"test:integration:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260818.1",
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"hono": "^4.13.3",
|
||||
"tsx": "^4.23.12",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"vitest": "^4.1.11",
|
||||
"wrangler": "^4.124.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@immich/kysely-adapter-cloudflare": "^0.1.0",
|
||||
"itty-router": "^5.0.24",
|
||||
"kysely": "^0.29.5",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"pg": "^8.23.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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`);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
// 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';
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { SurveyDO } from './survey-do';
|
||||
|
||||
export default {
|
||||
async fetch(): Promise<Response> {
|
||||
return new Response('This worker only hosts Durable Objects', { status: 404 });
|
||||
},
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
/** 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);
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
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);
|
||||
|
||||
// The unload beacon submits over HTTP even in WebSocket mode, so without this
|
||||
// the cached choice answers miss whatever was flushed on the way out and the
|
||||
// live tallies under-count once the respondent completes.
|
||||
for (const a of answers) {
|
||||
this.cache.setAnswer(respondentId, a.questionId, a.value, a.otherText ?? null);
|
||||
}
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,317 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
// Cache only after the write lands: a throw here leaves answers in
|
||||
// choiceAnswers that were never persisted, and completion would fold them
|
||||
// into the live tallies.
|
||||
for (const a of answers) {
|
||||
cache.setAnswer(respondentId, a.questionId, a.value, a.otherText ?? null);
|
||||
}
|
||||
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 ?? {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { sql, type Kysely } from 'kysely';
|
||||
// kysely 0.29 moved the migrator out of the root entry point.
|
||||
import { Migrator, type Migration, type MigrationProvider } from 'kysely/migration';
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 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\/([^/]+)(\/.*)?$/;
|
||||
@@ -1,119 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export class ServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ServiceError';
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { AutoRouterType, IRequest } from 'itty-router';
|
||||
|
||||
export type AppRouter = AutoRouterType<IRequest, [Env, ExecutionContext]>;
|
||||
@@ -1,68 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
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 };
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.integration.ts'],
|
||||
testTimeout: 15000,
|
||||
hookTimeout: 15000,
|
||||
sequence: {
|
||||
concurrent: false,
|
||||
},
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"$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",
|
||||
},
|
||||
],
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$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",
|
||||
},
|
||||
],
|
||||
},
|
||||
// This config only drives `wrangler build`; Terraform deploys the worker and
|
||||
// applies the migration itself, so this must stay in sync with the block in
|
||||
// deployment/modules/cloudflare/workers/survey/worker.tf.
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["SurveyDO"],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"$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`.
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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:24-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@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
|
||||
environment:
|
||||
POSTGRES_DB: survey
|
||||
POSTGRES_USER: survey
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-survey}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
profiles:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
survey-data:
|
||||
pgdata:
|
||||
@@ -1,246 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
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 };
|
||||
@@ -1,269 +0,0 @@
|
||||
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 };
|
||||
@@ -1,505 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,685 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -1,631 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
// See the matching note in the root eslint.config.ts: typescript-eslint
|
||||
// cannot infer a tsconfigRootDir when two flat configs are loaded in one
|
||||
// process, so each one pins its own.
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
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/'],
|
||||
},
|
||||
];
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"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.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3-cloud": "^1.2.9",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/node": "^26.6.1",
|
||||
"@types/oidc-provider": "^9.11.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.11.0",
|
||||
"oidc-provider": "^9.11.3",
|
||||
"svelte": "^5.56.9",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@immich/kysely-adapter-cloudflare": "^0.1.0",
|
||||
"@immich/ui": "^0.89.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-selection": "^3.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"kysely": "^0.29.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "^0.9.78"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
// 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 {};
|
||||
@@ -1,17 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,332 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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 = '/';
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,252 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { WsOperations, WsPushEvents } from '$shared/ws-protocol';
|
||||
|
||||
export interface SurveyWsClient {
|
||||
request<K extends keyof WsOperations>(op: K, data: WsOperations[K]['request']): Promise<WsOperations[K]['response']>;
|
||||
|
||||
on<K extends keyof WsPushEvents>(event: K, callback: (data: WsPushEvents[K]) => void): () => void;
|
||||
|
||||
/** Fires immediately with the current state on subscribe, then on each change. */
|
||||
onConnectionChange(callback: (state: 'connecting' | 'open' | 'closed' | 'failed') => void): () => void;
|
||||
|
||||
close(): void;
|
||||
|
||||
readonly connected: boolean;
|
||||
}
|
||||
|
||||
let requestCounter = 0;
|
||||
|
||||
export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent' | 'editor'): SurveyWsClient {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}/api/s/${slug}/ws?type=${type}`;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let closed = false;
|
||||
let failures = 0;
|
||||
const MAX_FAILURES = 3;
|
||||
|
||||
type ConnState = 'connecting' | 'open' | 'closed' | 'failed';
|
||||
let state: ConnState = 'connecting';
|
||||
const stateListeners = new Set<(s: ConnState) => void>();
|
||||
function setState(next: ConnState) {
|
||||
state = next;
|
||||
for (const cb of stateListeners) cb(next);
|
||||
}
|
||||
|
||||
const pushListeners = new Map<string, Array<(data: unknown) => void>>();
|
||||
const pendingRequests = new Map<string, { resolve: (data: unknown) => void; reject: (error: Error) => void }>();
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
setState('connecting');
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
failures = 0;
|
||||
setState('open');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'response' && msg.requestId) {
|
||||
const pending = pendingRequests.get(msg.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(msg.requestId);
|
||||
if (msg.error) {
|
||||
pending.reject(new Error(msg.error));
|
||||
} else {
|
||||
pending.resolve(msg.data);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'push' && msg.event) {
|
||||
const cbs = pushListeners.get(msg.event);
|
||||
if (cbs) for (const cb of cbs) cb(msg.data);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
for (const [, pending] of pendingRequests) {
|
||||
pending.reject(new Error('WebSocket closed'));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
|
||||
if (closed) return;
|
||||
failures++;
|
||||
if (failures < MAX_FAILURES) {
|
||||
setState('closed');
|
||||
reconnectTimer = setTimeout(connect, 3000);
|
||||
} else {
|
||||
setState('failed');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
get connected() {
|
||||
return ws?.readyState === WebSocket.OPEN;
|
||||
},
|
||||
|
||||
async request<K extends keyof WsOperations>(
|
||||
op: K,
|
||||
data: WsOperations[K]['request'],
|
||||
): Promise<WsOperations[K]['response']> {
|
||||
// Capture `ws` locally: a concurrent reconnect reassigns the outer
|
||||
// binding, and listeners must come off the socket they went onto.
|
||||
if (ws?.readyState === WebSocket.CONNECTING) {
|
||||
const socket = ws;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
socket.removeEventListener('open', onOpen);
|
||||
socket.removeEventListener('error', onError);
|
||||
socket.removeEventListener('close', onClose);
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
socket.removeEventListener('open', onOpen);
|
||||
socket.removeEventListener('error', onError);
|
||||
socket.removeEventListener('close', onClose);
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
};
|
||||
const onClose = onError;
|
||||
socket.addEventListener('open', onOpen);
|
||||
socket.addEventListener('error', onError);
|
||||
socket.addEventListener('close', onClose);
|
||||
});
|
||||
}
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
|
||||
const requestId = `r${++requestCounter}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
reject(new Error('Request timeout'));
|
||||
}, 30_000);
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(result as WsOperations[K]['response']);
|
||||
},
|
||||
reject: (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
|
||||
ws!.send(JSON.stringify({ type: 'request', requestId, op, data }));
|
||||
});
|
||||
},
|
||||
|
||||
on<K extends keyof WsPushEvents>(event: K, callback: (data: WsPushEvents[K]) => void): () => void {
|
||||
const key = event as string;
|
||||
if (!pushListeners.has(key)) pushListeners.set(key, []);
|
||||
const cb = callback as (data: unknown) => void;
|
||||
pushListeners.get(key)!.push(cb);
|
||||
return () => {
|
||||
const list = pushListeners.get(key);
|
||||
if (list) {
|
||||
const idx = list.indexOf(cb);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
onConnectionChange(callback: (s: ConnState) => void): () => void {
|
||||
stateListeners.add(callback);
|
||||
callback(state);
|
||||
return () => {
|
||||
stateListeners.delete(callback);
|
||||
};
|
||||
},
|
||||
|
||||
close() {
|
||||
closed = true;
|
||||
clearTimeout(reconnectTimer);
|
||||
ws?.close();
|
||||
setState('closed');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Two separate registries so lookups are unambiguous: slug+type for
|
||||
// survey-taking / results viewing, surveyId for admin-registered clients. A
|
||||
// single map with prefix matching could return either the editor or the viewer
|
||||
// connection for the same slug at random.
|
||||
|
||||
type WsType = 'viewer' | 'respondent' | 'editor';
|
||||
|
||||
const connectionsBySlug = new Map<string, SurveyWsClient>(); // key: `${slug}:${type}`
|
||||
const connectionsBySurveyId = new Map<string, SurveyWsClient>();
|
||||
|
||||
/** Get or create a WS client for a survey */
|
||||
export function getSurveyWs(slug: string, type: WsType): SurveyWsClient {
|
||||
const key = `${slug}:${type}`;
|
||||
const existing = connectionsBySlug.get(key);
|
||||
if (existing?.connected) return existing;
|
||||
existing?.close();
|
||||
|
||||
const client = createSurveyWsClient(slug, type);
|
||||
connectionsBySlug.set(key, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Get an existing WS client by survey ID (admin flows only) */
|
||||
export function getWsClientById(surveyId: string): SurveyWsClient | undefined {
|
||||
const conn = connectionsBySurveyId.get(surveyId);
|
||||
return conn?.connected ? conn : undefined;
|
||||
}
|
||||
|
||||
/** Get an existing WS client by slug and type */
|
||||
export function getWsClientBySlug(slug: string, type: WsType = 'respondent'): SurveyWsClient | undefined {
|
||||
const conn = connectionsBySlug.get(`${slug}:${type}`);
|
||||
return conn?.connected ? conn : undefined;
|
||||
}
|
||||
|
||||
export function registerWsClient(surveyId: string, client: SurveyWsClient): void {
|
||||
connectionsBySurveyId.set(surveyId, client);
|
||||
}
|
||||
|
||||
export function closeSurveyWs(slug: string, type: WsType): void {
|
||||
const key = `${slug}:${type}`;
|
||||
const conn = connectionsBySlug.get(key);
|
||||
if (conn) {
|
||||
conn.close();
|
||||
connectionsBySlug.delete(key);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user