Files
sneakyklaus/docs/designs/v0.2.0/api-spec.md
Phil Skentelbery eaafa78cf3 feat: add Participant and MagicToken models with automatic migrations
Implements Phase 2 infrastructure for participant registration and authentication:

Database Models:
- Add Participant model with exchange scoping and soft deletes
- Add MagicToken model for passwordless authentication
- Add participants relationship to Exchange model
- Include proper indexes and foreign key constraints

Migration Infrastructure:
- Generate Alembic migration for new models
- Create entrypoint.sh script for automatic migrations on container startup
- Update Containerfile to use entrypoint script and include uv binary
- Remove db.create_all() in favor of migration-based schema management

This establishes the foundation for implementing stories 4.1-4.3, 5.1-5.3, and 10.1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 16:23:47 -07:00

1515 lines
36 KiB
Markdown

# API Specification - v0.2.0
**Version**: 0.2.0
**Date**: 2025-12-22
**Status**: Phase 2 Design
## Introduction
This document defines all HTTP routes, request/response formats, form fields, validation rules, and authentication requirements for Sneaky Klaus. The application uses server-side rendering with Flask, so most routes return HTML rather than JSON.
**Phase 2 Additions**: This version adds participant-facing routes for registration (`/exchange/<slug>/register`), magic link authentication (`/auth/participant/magic/<token>`), and accessing magic links (`/exchange/<slug>/request-access`). The participant routes defined in v0.1.0 are now fully active.
## Architecture
### Blueprint Organization
Flask routes are organized into blueprints by functional area:
| Blueprint | Prefix | Purpose |
|-----------|--------|---------|
| `auth` | `/auth` | Authentication (admin login, magic links) |
| `admin` | `/admin` | Admin dashboard and exchange management |
| `participant` | `/participant` | Participant registration and dashboard |
| `public` | `/` | Public pages (landing, health check) |
### Authentication
**Session-Based Authentication**:
- Server-side sessions stored in database
- Session cookies with Secure, HttpOnly, SameSite=Lax flags
- CSRF protection on all state-changing requests (POST, PUT, DELETE)
**Auth Decorators**:
- `@login_required`: Any authenticated user (admin or participant)
- `@admin_required`: Admin only
- `@participant_required`: Participant only
- `@exchange_access_required`: Participant has access to specific exchange
### Request/Response Patterns
**HTML Responses**: Most routes return rendered Jinja2 templates
**Redirects**: POST requests redirect to GET (Post-Redirect-Get pattern)
**Flash Messages**: Success/error messages displayed via Flask flash()
**Form Validation**: WTForms with server-side validation
**CSRF Tokens**: All forms include CSRF token
### Error Handling
**HTTP Status Codes**:
- `200 OK`: Successful request
- `302 Found`: Redirect (common after POST)
- `400 Bad Request`: Form validation failure
- `401 Unauthorized`: Not logged in
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource doesn't exist
- `429 Too Many Requests`: Rate limit exceeded
- `500 Internal Server Error`: Application error
**Error Pages**: Custom error templates for 404, 403, 500
---
## Public Blueprint
### GET /
**Purpose**: Landing page (redirects to appropriate dashboard if logged in)
**Authentication**: None
**Response**:
- If admin logged in: Redirect to `/admin/dashboard`
- If participant logged in: Redirect to `/participant/dashboard`
- Otherwise: Render landing page template
**Template**: `public/landing.html`
**Content**:
- Application description
- Links to admin login
- Example registration link (non-functional demo)
---
### GET /health
**Purpose**: Health check endpoint for monitoring
**Authentication**: None
**Response**: JSON
```json
{
"status": "healthy",
"timestamp": "2025-12-22T10:30:00Z",
"database": "connected",
"scheduler": "running"
}
```
**Status Codes**:
- `200 OK`: All systems healthy
- `503 Service Unavailable`: Database or critical component failure
**Checks**:
- Database connectivity
- Scheduler running status
---
## Auth Blueprint
### GET /auth/admin/login
**Purpose**: Admin login page
**Authentication**: None (redirects to dashboard if already logged in)
**Query Parameters**: None
**Response**: Render login form
**Template**: `auth/admin_login.html`
**Form Fields**:
- `email`: Email address
- `password`: Password
- `csrf_token`: CSRF protection
---
### POST /auth/admin/login
**Purpose**: Admin login submission
**Authentication**: None
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | String | Yes | Valid email format |
| `password` | String | Yes | Non-empty |
| `csrf_token` | String | Yes | Valid CSRF token |
**Rate Limit**: 5 attempts per 15 minutes per email
**Success Response**:
- Create admin session
- Redirect to `/admin/dashboard`
- Flash message: "Welcome back!"
**Error Response**:
- Re-render login form with error message
- Errors:
- "Invalid email or password" (generic, doesn't reveal which)
- "Too many login attempts. Try again in 15 minutes." (rate limit)
**Status Codes**:
- `302 Found`: Successful login (redirect)
- `400 Bad Request`: Validation failure
- `429 Too Many Requests`: Rate limit exceeded
---
### GET /auth/admin/logout
**Purpose**: Admin logout
**Authentication**: Admin required
**Response**:
- Destroy session
- Redirect to `/auth/admin/login`
- Flash message: "Logged out successfully"
---
### GET /auth/admin/forgot-password
**Purpose**: Password reset request page
**Authentication**: None
**Response**: Render password reset request form
**Template**: `auth/forgot_password.html`
**Form Fields**:
- `email`: Admin email address
- `csrf_token`: CSRF protection
---
### POST /auth/admin/forgot-password
**Purpose**: Request password reset email
**Authentication**: None
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | String | Yes | Valid email format |
| `csrf_token` | String | Yes | Valid CSRF token |
**Rate Limit**: 3 requests per hour per email
**Response** (always same, security best practice):
- Redirect to `/auth/admin/login`
- Flash message: "If an account exists, you'll receive a password reset email."
**Backend Actions**:
- If email matches admin: Create password reset token, send email
- If email doesn't match: Do nothing (same timing to prevent enumeration)
**Email Content**:
- Subject: "Password Reset Request - Sneaky Klaus"
- Body: Password reset link with token
- Link: `/auth/admin/reset-password/{token}`
- Expiration: 1 hour
---
### GET /auth/admin/reset-password/<token>
**Purpose**: Password reset form
**Authentication**: None
**URL Parameters**:
- `token`: Password reset token (string)
**Response**:
- If token valid: Render password reset form
- If token invalid/expired: Render error page with link to request new reset
**Template**: `auth/reset_password.html`
**Form Fields**:
- `password`: New password
- `password_confirm`: Confirm new password
- `csrf_token`: CSRF protection
- `token`: Hidden field with token value
---
### POST /auth/admin/reset-password/<token>
**Purpose**: Submit new password
**Authentication**: None
**URL Parameters**:
- `token`: Password reset token
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `password` | String | Yes | Min 12 characters |
| `password_confirm` | String | Yes | Must match `password` |
| `token` | String | Yes | Hidden field, must be valid |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Password minimum 12 characters
- Passwords must match
- Token must be valid (not expired, not used)
**Success Response**:
- Update admin password
- Invalidate token
- Redirect to `/auth/admin/login`
- Flash message: "Password reset successful. Please log in."
**Error Response**:
- Re-render form with errors
- Errors:
- "Passwords do not match"
- "Password must be at least 12 characters"
- "Invalid or expired reset link"
---
### GET /auth/participant/magic/<token>
**Purpose**: Magic link authentication for participants
**Authentication**: None
**URL Parameters**:
- `token`: Magic link token (string)
**Response**:
- If token valid:
- Create participant session
- Mark token as used
- Redirect to `/participant/dashboard`
- Flash message: "Welcome back!"
- If token invalid/expired/used:
- Render error page
- Show option to request new magic link
- Template: `auth/invalid_token.html`
**Status Codes**:
- `302 Found`: Valid token (redirect)
- `400 Bad Request`: Invalid token
---
### GET /auth/participant/logout
**Purpose**: Participant logout
**Authentication**: Participant required
**Response**:
- Destroy session
- Redirect to `/`
- Flash message: "Logged out successfully"
---
## Admin Blueprint
### GET /admin/dashboard
**Purpose**: Admin dashboard showing all exchanges
**Authentication**: Admin required
**Query Parameters**:
- `state` (optional): Filter by exchange state (draft, registration_open, etc.)
**Response**: Render dashboard
**Template**: `admin/dashboard.html`
**Template Data**:
```python
{
"exchanges": [
{
"id": 1,
"name": "Family Christmas 2025",
"state": "registration_open",
"participant_count": 12,
"exchange_date": "2025-12-25",
"max_participants": 20
},
# ... more exchanges
],
"active_count": 3,
"completed_count": 5,
"draft_count": 2
}
```
**Display**:
- List all exchanges grouped by state
- Summary counts
- Quick actions per exchange (view, edit, delete)
- "Create New Exchange" button
---
### GET /admin/exchange/new
**Purpose**: Create new exchange form
**Authentication**: Admin required
**Response**: Render exchange creation form
**Template**: `admin/exchange_form.html`
**Form Fields**:
- `name`: Exchange name
- `description`: Optional description
- `budget`: Gift budget (freeform text)
- `max_participants`: Maximum participants
- `registration_close_date`: Date + time
- `exchange_date`: Date + time
- `timezone`: Dropdown of timezones
- `csrf_token`: CSRF protection
---
### POST /admin/exchange/new
**Purpose**: Create new exchange
**Authentication**: Admin required
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | String | Yes | 1-255 characters |
| `description` | Text | No | 0-2000 characters |
| `budget` | String | Yes | 1-100 characters |
| `max_participants` | Integer | Yes | ≥ 3 |
| `registration_close_date` | DateTime | Yes | Must be future date |
| `exchange_date` | DateTime | Yes | Must be after close date |
| `timezone` | String | Yes | Valid IANA timezone |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation Rules**:
- `registration_close_date` must be in the future
- `exchange_date` must be after `registration_close_date`
- `max_participants` minimum 3
- `timezone` must be valid IANA timezone name
**Success Response**:
- Create exchange in "draft" state
- Redirect to `/admin/exchange/<id>`
- Flash message: "Exchange created successfully!"
**Error Response**:
- Re-render form with error messages
- Preserve form values
---
### GET /admin/exchange/<id>
**Purpose**: View exchange details
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID (integer)
**Response**: Render exchange detail page
**Template**: `admin/exchange_detail.html`
**Template Data**:
```python
{
"exchange": {
"id": 1,
"name": "Family Christmas 2025",
"description": "Annual family gift exchange",
"budget": "$20-30",
"max_participants": 20,
"registration_close_date": "2025-12-15T23:59:00",
"exchange_date": "2025-12-25T18:00:00",
"timezone": "America/New_York",
"state": "registration_open",
"participant_count": 12,
"registration_link": "https://app.example.com/exchange/abc123/register"
},
"participants": [
{
"id": 1,
"name": "Alice Smith",
"email": "alice@example.com",
"registered_at": "2025-11-01T10:00:00",
"gift_ideas": "Books, coffee, plants"
},
# ... more participants
],
"exclusions": [
{
"id": 1,
"participant_a": "Alice Smith",
"participant_b": "Bob Jones"
},
# ... more exclusions
],
"matches": [ # Only present if state == "matched"
{
"giver": "Alice Smith",
"receiver": "Carol White"
},
# ... more matches
]
}
```
**State-Specific Actions**:
- Draft: Edit, Open Registration, Delete
- Registration Open: Edit, Close Registration, Delete
- Registration Closed: Reopen Registration, Configure Exclusions, Match, Delete
- Matched: View Matches, Re-match, Reopen Registration, Mark Complete, Delete
- Completed: Delete (with days until purge indicator)
---
### GET /admin/exchange/<id>/edit
**Purpose**: Edit exchange form
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Response**: Render exchange edit form
**Template**: `admin/exchange_form.html` (same as create)
**Pre-populated**: All current exchange values
**Restrictions**:
- If state is "matched" or "completed": Show read-only view with message "Cannot edit after matching"
- Otherwise: Allow editing all fields
---
### POST /admin/exchange/<id>/edit
**Purpose**: Update exchange
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**: Same as POST /admin/exchange/new
**Validation**: Same as create, plus:
- Cannot edit if state is "matched" or "completed"
**Success Response**:
- Update exchange
- Redirect to `/admin/exchange/<id>`
- Flash message: "Exchange updated successfully!"
**Error Response**:
- Re-render form with errors
---
### POST /admin/exchange/<id>/delete
**Purpose**: Delete exchange
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `confirm` | String | Yes | Must be "DELETE" |
| `csrf_token` | String | Yes | Valid CSRF token |
**Confirmation**: Requires typing "DELETE" to confirm
**Success Response**:
- Delete exchange (cascades to participants, matches, exclusions)
- Redirect to `/admin/dashboard`
- Flash message: "Exchange deleted successfully"
**Error Response**:
- Redirect back with error message
---
### POST /admin/exchange/<id>/state/open-registration
**Purpose**: Open registration for exchange
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must be in "draft" state
**Success Response**:
- Update state to "registration_open"
- Redirect to `/admin/exchange/<id>`
- Flash message: "Registration is now open!"
**Error Response**:
- Redirect back with error message
---
### POST /admin/exchange/<id>/state/close-registration
**Purpose**: Close registration
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must be in "registration_open" state
**Success Response**:
- Update state to "registration_closed"
- Redirect to `/admin/exchange/<id>`
- Flash message: "Registration closed. You can now configure exclusions and match participants."
---
### POST /admin/exchange/<id>/state/reopen-registration
**Purpose**: Reopen registration (pre or post matching)
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `confirm` | Boolean | Conditional | Required if state is "matched" |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must be in "registration_closed" or "matched" state
**Behavior**:
- If from "registration_closed": Simple state change
- If from "matched": Delete all matches, require confirmation
**Success Response**:
- Update state to "registration_open"
- Clear matches if applicable
- Redirect to `/admin/exchange/<id>`
- Flash message: "Registration reopened" (+ warning if matches cleared)
---
### GET /admin/exchange/<id>/exclusions
**Purpose**: Manage exclusion rules
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Response**: Render exclusion management page
**Template**: `admin/exclusions.html`
**Template Data**:
```python
{
"exchange": {...},
"participants": [
{"id": 1, "name": "Alice Smith"},
{"id": 2, "name": "Bob Jones"},
# ...
],
"exclusions": [
{
"id": 1,
"participant_a": {"id": 1, "name": "Alice Smith"},
"participant_b": {"id": 2, "name": "Bob Jones"}
},
# ...
]
}
```
**Display**:
- List of current exclusions with remove button
- Form to add new exclusion (two dropdowns for participants)
- Validation warning if exclusions make matching impossible
---
### POST /admin/exchange/<id>/exclusions
**Purpose**: Add exclusion rule
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `participant_a_id` | Integer | Yes | Valid participant in exchange |
| `participant_b_id` | Integer | Yes | Valid participant in exchange |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Both participants must exist and belong to this exchange
- Participants must be different
- Exclusion doesn't already exist
- Exchange must be in "registration_closed" state
**Success Response**:
- Create exclusion rule (store with lower ID as participant_a)
- Redirect to `/admin/exchange/<id>/exclusions`
- Flash message: "Exclusion added"
**Error Response**:
- Redirect back with error message
---
### POST /admin/exchange/<id>/exclusions/<exclusion_id>/delete
**Purpose**: Remove exclusion rule
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
- `exclusion_id`: Exclusion rule ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `csrf_token` | String | Yes | Valid CSRF token |
**Success Response**:
- Delete exclusion
- Redirect to `/admin/exchange/<id>/exclusions`
- Flash message: "Exclusion removed"
---
### POST /admin/exchange/<id>/match
**Purpose**: Trigger matching algorithm
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must be in "registration_closed" state
- Minimum 3 participants (non-withdrawn)
- Matching must be possible given exclusions
**Success Response**:
- Run matching algorithm
- Create match records
- Update state to "matched"
- Send match notification emails to all participants
- Redirect to `/admin/exchange/<id>`
- Flash message: "Matching complete! Participants have been notified."
**Error Response**:
- If matching impossible:
- Remain in "registration_closed" state
- Redirect to `/admin/exchange/<id>/exclusions`
- Flash error: "Matching failed: [reason]. Please adjust exclusion rules."
- Show which exclusions are problematic
**Matching Failure Reasons**:
- "Too many exclusions prevent a valid assignment"
- "Participant [name] has too many exclusions"
- "No valid single-cycle assignment possible"
---
### POST /admin/exchange/<id>/rematch
**Purpose**: Clear current matches and re-run matching
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `confirm` | Boolean | Yes | Must be true |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must be in "matched" state
**Success Response**:
- Delete existing matches
- Run matching algorithm
- Create new match records
- Send updated match notifications
- Redirect to `/admin/exchange/<id>`
- Flash message: "Re-matching complete! Participants have been notified of new assignments."
**Error Response**:
- Same as POST /admin/exchange/<id>/match
---
### GET /admin/exchange/<id>/matches
**Purpose**: View all matches (admin only)
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Response**: Render matches list
**Template**: `admin/matches.html`
**Template Data**:
```python
{
"exchange": {...},
"matches": [
{
"giver": {
"id": 1,
"name": "Alice Smith",
"email": "alice@example.com"
},
"receiver": {
"id": 3,
"name": "Carol White",
"gift_ideas": "Books, coffee"
}
},
# ...
]
}
```
**Display**:
- Table showing giver → receiver
- Option to download as CSV
- Warning message: "This information is confidential. Do not share matches with participants."
---
### POST /admin/exchange/<id>/complete
**Purpose**: Mark exchange as complete
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must be in "matched" state
**Success Response**:
- Update state to "completed"
- Set `completed_at` timestamp
- Redirect to `/admin/exchange/<id>`
- Flash message: "Exchange marked complete. Data will be purged in 30 days."
---
### POST /admin/exchange/<id>/participant/<participant_id>/remove
**Purpose**: Remove participant from exchange
**Authentication**: Admin required
**URL Parameters**:
- `id`: Exchange ID
- `participant_id`: Participant ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `confirm` | Boolean | Yes | Must be true |
| `csrf_token` | String | Yes | Valid CSRF token |
**Behavior**:
- If exchange not matched: Simply remove participant
- If exchange matched: Delete matches, require re-match
**Success Response**:
- Set participant `withdrawn_at` timestamp (soft delete)
- Delete related exclusions
- If matched: Delete all matches, revert state to "registration_closed"
- Send removal notification email to participant
- Redirect to `/admin/exchange/<id>`
- Flash message: "Participant removed" (+ warning if re-match required)
---
### GET /admin/settings
**Purpose**: Admin notification preferences
**Authentication**: Admin required
**Response**: Render settings page
**Template**: `admin/settings.html`
**Template Data**:
```python
{
"global_preferences": {
"new_registration": True,
"participant_withdrawal": True,
"matching_complete": True
},
"exchange_preferences": {
1: { # Exchange ID
"exchange_name": "Family Christmas 2025",
"new_registration": False,
"participant_withdrawal": True,
"matching_complete": True
},
# ... more exchanges
}
}
```
**Display**:
- Global default preferences (checkboxes)
- Per-exchange overrides (optional)
---
### POST /admin/settings
**Purpose**: Update notification preferences
**Authentication**: Admin required
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `global_new_registration` | Boolean | No | - |
| `global_participant_withdrawal` | Boolean | No | - |
| `global_matching_complete` | Boolean | No | - |
| `exchange_{id}_new_registration` | Boolean | No | Per exchange |
| `exchange_{id}_participant_withdrawal` | Boolean | No | Per exchange |
| `exchange_{id}_matching_complete` | Boolean | No | Per exchange |
| `csrf_token` | String | Yes | Valid CSRF token |
**Success Response**:
- Update global and per-exchange preferences
- Redirect to `/admin/settings`
- Flash message: "Notification preferences updated"
---
## Participant Blueprint
### GET /exchange/<slug>/register
**Purpose**: Participant registration page
**Authentication**: None
**URL Parameters**:
- `slug`: Exchange registration slug (unique identifier, not numeric ID)
**Response**: Render registration page
**Template**: `participant/register.html`
**Template Data**:
```python
{
"exchange": {
"name": "Family Christmas 2025",
"description": "Annual family gift exchange",
"budget": "$20-30",
"exchange_date": "2025-12-25",
"max_participants": 20,
"current_participants": 12,
"registration_open": True
}
}
```
**Display**:
- Exchange details
- If registration open:
- Registration form
- "Already registered? Request access link" button
- If registration closed:
- Message: "Registration is closed"
- "Already registered? Request access link" button
- If exchange doesn't exist: 404 error
---
### POST /exchange/<slug>/register
**Purpose**: Submit participant registration
**Authentication**: None
**URL Parameters**:
- `slug`: Exchange registration slug
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | String | Yes | 1-255 characters |
| `email` | String | Yes | Valid email, unique in exchange |
| `gift_ideas` | Text | No | 0-10000 characters |
| `reminder_enabled` | Boolean | No | Default: True |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Email must be unique within this exchange
- Exchange must be in "registration_open" state
- Participant count must be under max_participants
**Success Response**:
- Create participant record
- Create magic token
- Send confirmation email with magic link
- Redirect to `/exchange/<slug>/register/success`
- Flash message: "Registration successful! Check your email for access link."
**Error Response**:
- Re-render form with errors
- Errors:
- "Email already registered for this exchange"
- "Registration is closed"
- "Exchange is full"
---
### GET /exchange/<slug>/register/success
**Purpose**: Registration success page
**Authentication**: None
**URL Parameters**:
- `slug`: Exchange registration slug
**Response**: Render success page
**Template**: `participant/register_success.html`
**Content**:
- Success message
- Instructions to check email
- Link to request another magic link
---
### POST /exchange/<slug>/request-access
**Purpose**: Request magic link for existing registration
**Authentication**: None
**URL Parameters**:
- `slug`: Exchange registration slug
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | String | Yes | Valid email format |
| `csrf_token` | String | Yes | Valid CSRF token |
**Rate Limit**: 3 requests per hour per email
**Response** (always same, security best practice):
- Redirect to `/exchange/<slug>/register/success`
- Flash message: "If you're registered, you'll receive an access link."
**Backend Actions**:
- If email registered in this exchange: Create magic token, send email
- If email not registered: Do nothing (same timing)
---
### GET /participant/dashboard
**Purpose**: Participant dashboard (after authentication)
**Authentication**: Participant required
**Response**: Render participant dashboard
**Template**: `participant/dashboard.html`
**Template Data**:
```python
{
"exchanges": [
{
"id": 1,
"name": "Family Christmas 2025",
"budget": "$20-30",
"exchange_date": "2025-12-25",
"state": "matched",
"my_match": {
"receiver_name": "Carol White",
"gift_ideas": "Books, coffee, plants"
} if matched else None,
"participant_count": 12,
"can_edit": True/False # Based on state
},
# ... more exchanges
]
}
```
**Display**:
- List of all exchanges participant is registered in
- For each exchange:
- Exchange details
- If matched: Show assigned recipient and gift ideas
- If not matched: Show waiting message
- Links to view participant list and edit profile
---
### GET /participant/exchange/<id>
**Purpose**: View specific exchange details
**Authentication**: Participant required, exchange access required
**URL Parameters**:
- `id`: Exchange ID
**Response**: Render exchange detail page
**Template**: `participant/exchange_detail.html`
**Template Data**:
```python
{
"exchange": {
"name": "Family Christmas 2025",
"description": "Annual family gift exchange",
"budget": "$20-30",
"exchange_date": "2025-12-25",
"state": "matched"
},
"my_match": {
"receiver_name": "Carol White",
"gift_ideas": "Books, coffee, plants"
} if matched else None,
"participants": [
{"name": "Alice Smith"},
{"name": "Bob Jones"},
# ... (names only, no emails or matches)
],
"my_profile": {
"name": "Alice Smith",
"email": "alice@example.com",
"gift_ideas": "Books, coffee",
"reminder_enabled": True
}
}
```
**Display**:
- Exchange information
- Assigned recipient (if matched)
- List of all participants (names only)
- Own profile with edit button
---
### GET /participant/exchange/<id>/edit
**Purpose**: Edit participant profile
**Authentication**: Participant required, exchange access required
**URL Parameters**:
- `id`: Exchange ID
**Response**: Render profile edit form
**Template**: `participant/edit_profile.html`
**Pre-populated**: Current participant data
**Restrictions**:
- Cannot edit if exchange is "matched" or "completed" (except gift_ideas and reminder_enabled)
- Cannot change email (show as read-only)
---
### POST /participant/exchange/<id>/edit
**Purpose**: Update participant profile
**Authentication**: Participant required, exchange access required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation | Notes |
|-------|------|----------|------------|-------|
| `name` | String | Yes | 1-255 characters | Disabled if matched |
| `gift_ideas` | Text | No | 0-10000 characters | Always editable |
| `reminder_enabled` | Boolean | No | - | Always editable |
| `csrf_token` | String | Yes | Valid CSRF token | |
**Validation**:
- Cannot change name if exchange is matched/completed
- Can always update gift_ideas and reminder_enabled
**Success Response**:
- Update participant record
- Redirect to `/participant/exchange/<id>`
- Flash message: "Profile updated"
**Error Response**:
- Re-render form with errors
---
### POST /participant/exchange/<id>/withdraw
**Purpose**: Withdraw from exchange
**Authentication**: Participant required, exchange access required
**URL Parameters**:
- `id`: Exchange ID
**Form Fields**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `confirm` | Boolean | Yes | Must be true |
| `csrf_token` | String | Yes | Valid CSRF token |
**Validation**:
- Exchange must not be in "matched" or "completed" state
**Success Response**:
- Set participant `withdrawn_at` timestamp
- Delete related exclusions
- Send withdrawal confirmation email
- Notify admin (if enabled)
- Redirect to `/participant/dashboard`
- Flash message: "You have withdrawn from the exchange"
**Error Response**:
- Redirect back with error
- Error: "Cannot withdraw after matching has occurred"
---
## Form Validation Details
### Common Validations
**Email**:
- Format: RFC 5322 compliant
- Length: Max 255 characters
- Normalized: Lowercase
**Password** (Admin):
- Min length: 12 characters
- No complexity requirements (follows NIST guidance)
**Exchange Name**:
- Min length: 1 character
- Max length: 255 characters
- Required
**Gift Ideas**:
- Max length: 10,000 characters
- Optional
- Multiline text
**Dates**:
- Format: ISO 8601 or localized format
- Validation: Must be future dates (for registration_close_date, exchange_date)
- Relationship: registration_close_date < exchange_date
**Timezone**:
- Must be valid IANA timezone name (e.g., "America/New_York")
- Dropdown with common timezones
### CSRF Protection
All state-changing requests (POST, PUT, DELETE) require valid CSRF token:
- Token generated server-side
- Embedded in forms as hidden field
- Validated on submission
- Token bound to user session
**Implementation**: Flask-WTF
---
## Rate Limiting
### Rate Limit Policies
| Endpoint | Limit | Window | Key |
|----------|-------|--------|-----|
| POST /auth/admin/login | 5 attempts | 15 minutes | email |
| POST /auth/admin/forgot-password | 3 requests | 1 hour | email |
| POST /exchange/<slug>/request-access | 3 requests | 1 hour | email |
| POST /exchange/<slug>/register | 10 requests | 1 hour | IP address |
### Rate Limit Response
**Status Code**: `429 Too Many Requests`
**Response**: Re-render form with error message
**Error Message**: "Too many attempts. Please try again later."
**Headers** (optional enhancement):
```
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640000000
```
---
## Email Notifications
Emails triggered by application actions (detailed in notifications component design).
### Triggered Emails
| Trigger | Recipient | Template |
|---------|-----------|----------|
| Participant registration | Participant | registration_confirmation |
| Magic link request | Participant | magic_link |
| Match assigned | Participant | match_notification |
| Reminder (scheduled) | Participant | reminder |
| Participant withdrawal | Participant | withdrawal_confirmation |
| New registration | Admin | admin_new_registration |
| Participant withdrawal | Admin | admin_participant_withdrawal |
| Matching complete | Admin | admin_matching_complete |
| Password reset request | Admin | password_reset |
---
## Response Headers
### Security Headers
Configured via Flask-Talisman:
```python
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: strict-origin-when-cross-origin
```
### Session Cookie
```python
Set-Cookie: session=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800
```
---
## Error Responses
### Form Validation Errors
**Format**: Flash message + inline field errors
**Example**:
```html
<div class="alert alert-error">
Please correct the errors below.
</div>
<form>
<div class="field error">
<label>Email</label>
<input type="email" name="email" value="invalid">
<span class="error-message">Invalid email format</span>
</div>
</form>
```
### HTTP Error Pages
**404 Not Found**:
- Template: `errors/404.html`
- Message: "Page not found"
- Link to dashboard or home
**403 Forbidden**:
- Template: `errors/403.html`
- Message: "You don't have permission to access this page"
**500 Internal Server Error**:
- Template: `errors/500.html`
- Message: "Something went wrong. Please try again later."
- Error logged with stack trace
**429 Too Many Requests**:
- Template: Re-render current page with error message
- Message: "Too many attempts. Please try again later."
---
## URL Naming Conventions
### URL Patterns
**Admin Routes**:
- `/admin/dashboard` - Dashboard
- `/admin/exchange/new` - Create exchange
- `/admin/exchange/<id>` - View exchange
- `/admin/exchange/<id>/edit` - Edit exchange
- `/admin/exchange/<id>/exclusions` - Manage exclusions
- `/admin/exchange/<id>/matches` - View matches
**Participant Routes**:
- `/exchange/<slug>/register` - Registration page (public)
- `/participant/dashboard` - Participant dashboard (authenticated)
- `/participant/exchange/<id>` - Exchange details (authenticated)
**Auth Routes**:
- `/auth/admin/login` - Admin login
- `/auth/admin/logout` - Admin logout
- `/auth/participant/magic/<token>` - Magic link
- `/auth/participant/logout` - Participant logout
### Slug vs ID
**Exchange Slug** (for public registration):
- Format: Random alphanumeric string (e.g., "abc123xyz")
- Length: 12 characters
- URL-safe
- Generated on exchange creation
- Used for public registration link
**Numeric ID** (for authenticated access):
- Format: Auto-increment integer
- Used in admin and authenticated participant routes
- Not exposed in public URLs
---
## Static Assets
### CSS
**Location**: `/static/css/`
**Files**:
- `main.css` - Global styles
- `admin.css` - Admin-specific styles
- `participant.css` - Participant-specific styles
**Loading**: Linked in base template
### JavaScript
**Location**: `/static/js/`
**Files**:
- `main.js` - Global utilities (copy-to-clipboard, form validation)
- `admin.js` - Admin-specific interactivity
**Philosophy**: Progressive enhancement, core functionality works without JS
### Images
**Location**: `/static/img/`
**Files**:
- Logo
- Icons
- Email header images
---
## API Versioning
**Current Version**: v0.1.0 (no API versioning in URLs)
**Future Consideration**: If REST API is added for programmatic access, use `/api/v1/` prefix
---
## References
- [Flask Routing Documentation](https://flask.palletsprojects.com/en/latest/quickstart/#routing)
- [WTForms Documentation](https://wtforms.readthedocs.io/)
- [Flask-WTF CSRF Protection](https://flask-wtf.readthedocs.io/en/stable/csrf.html)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
- [System Overview](./overview.md)
- [Data Model](./data-model.md)