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>
1515 lines
36 KiB
Markdown
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)
|