chore: initial project setup

Initialize Sneaky Klaus project with:
- uv package management and pyproject.toml
- Flask application structure (app.py, config.py)
- SQLAlchemy models for Admin and Exchange
- Alembic database migrations
- Pre-commit hooks configuration
- Development tooling (pytest, ruff, mypy)

Initial structure follows design documents in docs/:
- src/app.py: Application factory with Flask extensions
- src/config.py: Environment-based configuration
- src/models/: Admin and Exchange models
- migrations/: Alembic migration setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-22 11:28:15 -07:00
commit b077112aba
32 changed files with 10931 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,862 @@
# Authentication - v0.1.0
**Version**: 0.1.0
**Date**: 2025-12-22
**Status**: Initial Design
## Overview
This document defines the authentication and authorization flows for Sneaky Klaus. The system supports two types of users with different authentication mechanisms:
1. **Admin**: Single administrator account with password-based authentication
2. **Participants**: Passwordless authentication via magic links
Session management is handled by Flask-Session, which stores session data server-side in SQLite.
## Authentication Flows
### Initial Admin Setup Flow
The first-run experience for new Sneaky Klaus installations.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant App as Flask App
participant DB as SQLite Database
participant Session as Flask-Session
User->>Browser: Navigate to application
Browser->>App: GET /
App->>DB: Check if admin exists
DB-->>App: No admin found
App->>Browser: Redirect to /setup
Browser->>App: GET /setup
App-->>Browser: Render setup form
User->>Browser: Fill form (email, password)
Browser->>App: POST /setup
App->>App: Validate form (Flask-WTF)
App->>App: Hash password (bcrypt)
App->>DB: INSERT INTO admin
DB-->>App: Admin created (id=1)
App->>Session: Create admin session
Session-->>App: Session ID
App->>Browser: Set session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /admin/dashboard
Browser->>App: GET /admin/dashboard
App->>Session: Validate session
Session-->>App: Admin authenticated
App-->>Browser: Render dashboard
Note over Browser,App: Success message auto-dismisses after 5 seconds
```
#### Implementation Details
**First-Run Detection**:
- Check `SELECT COUNT(*) FROM admin` on application startup
- If count == 0, set `app.config['REQUIRES_SETUP'] = True`
- Before request handler checks flag and redirects to `/setup` if True
**Route**: `/setup`
**Methods**: GET, POST
**Authorization**:
- Accessible only when no admin exists
- Returns 404 if admin already exists
**Form Fields** (Flask-WTF):
- `email`: EmailField, required, validated with email validator
- `password`: PasswordField, required, min length 12 characters
- `password_confirm`: PasswordField, required, must match password
- CSRF token (automatic via Flask-WTF)
**POST Workflow**:
1. Validate form with Flask-WTF
2. Double-check no admin exists (prevent race condition)
3. Hash password with bcrypt (cost factor 12)
4. Insert admin record
5. Create session (Flask-Session)
6. Set session cookie (HttpOnly, Secure, SameSite=Lax)
7. Flash success message: "Admin account created successfully!"
8. Redirect to `/admin/dashboard`
**Auto-login**:
- Session created immediately after admin creation
- No separate login step required
- User automatically authenticated
---
### Admin Login Flow
Standard password-based authentication for the admin user.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant App as Flask App
participant RateLimit as Rate Limiter
participant DB as SQLite Database
participant Session as Flask-Session
User->>Browser: Navigate to /admin/login
Browser->>App: GET /admin/login
App-->>Browser: Render login form
User->>Browser: Fill form (email, password)
Browser->>App: POST /admin/login
App->>App: Validate form (Flask-WTF)
App->>RateLimit: Check rate limit for email
alt Rate limit exceeded
RateLimit-->>App: Too many attempts
App->>Browser: Flash error message
App->>Browser: Render login form (429 status)
Note over Browser: Error message persists until dismissed
else Within rate limit
RateLimit-->>App: Allowed
App->>DB: SELECT * FROM admin WHERE email = ?
DB-->>App: Admin record
alt Invalid credentials
App->>App: Verify password (bcrypt)
App->>RateLimit: Increment failure count
App->>Browser: Flash error message
App->>Browser: Render login form
Note over Browser: Error message persists until dismissed
else Valid credentials
App->>App: Verify password (bcrypt)
App->>RateLimit: Reset rate limit counter
App->>Session: Create admin session
Session-->>App: Session ID
App->>Browser: Set session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /admin/dashboard
Browser->>App: GET /admin/dashboard
App->>Session: Validate session
Session-->>App: Admin authenticated
App-->>Browser: Render dashboard
Note over Browser: Success message auto-dismisses after 5 seconds
end
end
```
#### Implementation Details
**Route**: `/admin/login`
**Methods**: GET, POST
**Authorization**:
- Accessible to unauthenticated users only
- Redirects to dashboard if already authenticated
**Form Fields** (Flask-WTF):
- `email`: EmailField, required
- `password`: PasswordField, required
- `remember_me`: BooleanField, optional (extends session duration)
- CSRF token (automatic via Flask-WTF)
**Rate Limiting**:
- **Policy**: 5 attempts per 15 minutes per email
- **Key**: `login:admin:{email_lowercase}`
- **Implementation**: Check `rate_limit` table before authentication
- **Failure Handling**: Increment attempt counter on failed login
- **Success Handling**: Reset counter on successful login
- **Lockout Message**: "Too many login attempts. Please try again in {minutes} minutes."
**POST Workflow**:
1. Validate form with Flask-WTF
2. Normalize email to lowercase
3. Check rate limit for `login:admin:{email}`
4. If rate limited: flash error, return 429
5. Query admin by email
6. If admin not found or password invalid:
- Increment rate limit counter
- Flash error: "Invalid email or password"
- Re-render form
7. If credentials valid:
- Reset rate limit counter
- Create session via Flask-Session
- Set session expiration based on `remember_me`:
- Checked: 30 days
- Unchecked: 7 days (default)
- Set session cookie
- Flash success: "Welcome back!"
- Redirect to `/admin/dashboard`
**Session Cookie Configuration**:
- `HttpOnly`: True (prevent JavaScript access)
- `Secure`: True (HTTPS only in production)
- `SameSite`: Lax (CSRF protection)
- `Max-Age`: Based on remember_me
---
### Admin Logout Flow
Terminates the admin session.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant App as Flask App
participant Session as Flask-Session
User->>Browser: Click logout
Browser->>App: POST /admin/logout
App->>Session: Get current session ID
App->>Session: Delete session
Session-->>App: Session deleted
App->>Browser: Clear session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /
Note over Browser: Success message auto-dismisses after 5 seconds
```
#### Implementation Details
**Route**: `/admin/logout`
**Methods**: POST (GET redirects to dashboard)
**Authorization**: Requires active admin session
**POST Workflow**:
1. Validate CSRF token
2. Delete session from Flask-Session store
3. Clear session cookie
4. Flash success: "You have been logged out"
5. Redirect to `/`
---
### Participant Magic Link Authentication Flow
Passwordless authentication for participants using time-limited magic links.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant Participant
participant Browser
participant App as Flask App
participant RateLimit as Rate Limiter
participant DB as SQLite Database
participant Email as Resend
participant Session as Flask-Session
Note over Participant,Browser: Participant requests access
Participant->>Browser: Navigate to /exchange/{slug}
Browser->>App: GET /exchange/{slug}
App->>DB: SELECT * FROM exchange WHERE slug = ?
DB-->>App: Exchange record
App-->>Browser: Render participant login form
Participant->>Browser: Enter email
Browser->>App: POST /exchange/{slug}/auth
App->>App: Validate form (Flask-WTF)
App->>RateLimit: Check rate limit for email
alt Rate limit exceeded
RateLimit-->>App: Too many requests
App->>Browser: Flash error message
App->>Browser: Render form (429 status)
Note over Browser: Error message persists until dismissed
else Within rate limit
RateLimit-->>App: Allowed
App->>DB: SELECT * FROM participant WHERE email = ? AND exchange_id = ?
alt Participant not found
App->>Browser: Flash error message
App->>Browser: Render form
Note over Browser: Generic error for security
else Participant found
DB-->>App: Participant record
App->>App: Generate magic token (32 bytes, secrets module)
App->>App: Hash token (SHA-256)
App->>DB: INSERT INTO magic_token
App->>Email: Send magic link email
Email-->>Participant: Email with magic link
App->>RateLimit: Increment request counter
App->>Browser: Flash success message
App->>Browser: Redirect to /exchange/{slug}/auth/sent
Note over Browser: Success message auto-dismisses after 5 seconds
end
end
Note over Participant,Email: Participant clicks magic link
Participant->>Email: Click link
Email->>Browser: Open /exchange/{slug}/auth/verify?token={token}
Browser->>App: GET /exchange/{slug}/auth/verify?token={token}
App->>App: Hash token
App->>DB: SELECT * FROM magic_token WHERE token_hash = ?
alt Token invalid/expired/used
DB-->>App: No token found / Token expired
App->>Browser: Flash error message
App->>Browser: Redirect to /exchange/{slug}
Note over Browser: Error message persists until dismissed
else Token valid
DB-->>App: Token record
App->>DB: UPDATE magic_token SET used_at = NOW()
App->>DB: SELECT * FROM participant WHERE id = ?
DB-->>App: Participant record
App->>Session: Create participant session
Session-->>App: Session ID
App->>Browser: Set session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /exchange/{slug}/dashboard
Browser->>App: GET /exchange/{slug}/dashboard
App->>Session: Validate session
Session-->>App: Participant authenticated
App-->>Browser: Render participant dashboard
Note over Browser: Success message auto-dismisses after 5 seconds
end
```
#### Implementation Details
**Route**: `/exchange/{slug}/auth`
**Methods**: GET, POST
**Authorization**: Public (unauthenticated)
**Form Fields** (Flask-WTF):
- `email`: EmailField, required
- CSRF token (automatic via Flask-WTF)
**Rate Limiting**:
- **Policy**: 3 requests per hour per email
- **Key**: `magic_link:{email_lowercase}`
- **Lockout Message**: "Too many magic link requests. Please try again in {minutes} minutes."
**POST Workflow** (Request Magic Link):
1. Validate form with Flask-WTF
2. Normalize email to lowercase
3. Check rate limit for `magic_link:{email}`
4. If rate limited: flash error, return 429
5. Query participant by email and exchange_id
6. If participant not found:
- Flash generic error: "If this email is registered, you will receive a magic link."
- Return success response (prevent email enumeration)
- Do NOT send email
7. If participant found but withdrawn:
- Same as not found (prevent information disclosure)
8. If participant found and active:
- Generate token: 32 bytes from `secrets.token_urlsafe()`
- Hash token: SHA-256
- Store hash in `magic_token` table with:
- `token_type`: 'magic_link'
- `email`: participant email
- `participant_id`: participant ID
- `exchange_id`: exchange ID
- `expires_at`: NOW() + 1 hour
- Send email with magic link
- Increment rate limit counter
- Flash success: "Check your email for a magic link!"
- Redirect to `/exchange/{slug}/auth/sent`
**Route**: `/exchange/{slug}/auth/verify`
**Methods**: GET
**Query Parameters**:
- `token`: Magic token (URL-safe base64)
**GET Workflow** (Verify Token):
1. Extract token from query string
2. Hash token with SHA-256
3. Query `magic_token` table by hash
4. Validate token:
- Exists in database
- `expires_at` > NOW()
- `used_at` IS NULL
- `token_type` = 'magic_link'
5. If invalid:
- Flash error: "This magic link is invalid or has expired."
- Redirect to `/exchange/{slug}`
6. If valid:
- Mark token as used: `UPDATE magic_token SET used_at = NOW()`
- Load participant record
- Create session via Flask-Session with:
- `user_id`: participant.id
- `user_type`: 'participant'
- `exchange_id`: exchange.id
- Set session cookie (7-day expiration)
- Flash success: "Welcome, {participant.name}!"
- Redirect to `/exchange/{slug}/dashboard`
**Token Generation**:
```python
import secrets
import hashlib
# Generate token
token = secrets.token_urlsafe(32) # 32 bytes = 43 URL-safe characters
# Hash for storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Magic link URL
magic_link = f"{base_url}/exchange/{slug}/auth/verify?token={token}"
```
---
### Participant Logout Flow
Terminates the participant session.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant Participant
participant Browser
participant App as Flask App
participant Session as Flask-Session
Participant->>Browser: Click logout
Browser->>App: POST /exchange/{slug}/logout
App->>Session: Get current session ID
App->>Session: Delete session
Session-->>App: Session deleted
App->>Browser: Clear session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /exchange/{slug}
Note over Browser: Success message auto-dismisses after 5 seconds
```
#### Implementation Details
**Route**: `/exchange/{slug}/logout`
**Methods**: POST
**Authorization**: Requires active participant session for this exchange
**POST Workflow**:
1. Validate CSRF token
2. Verify session belongs to participant for this exchange
3. Delete session from Flask-Session store
4. Clear session cookie
5. Flash success: "You have been logged out"
6. Redirect to `/exchange/{slug}`
---
## Session Management
### Flask-Session Configuration
**Backend**: SQLAlchemy (SQLite)
**Table**: `sessions` (created and managed by Flask-Session)
**Configuration**:
```python
app.config['SESSION_TYPE'] = 'sqlalchemy'
app.config['SESSION_SQLALCHEMY'] = db # SQLAlchemy instance
app.config['SESSION_PERMANENT'] = True
app.config['SESSION_USE_SIGNER'] = True # Sign session cookies
app.config['SESSION_KEY_PREFIX'] = 'sk:' # Prefix for session keys
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Default
```
### Session Data Structure
**Admin Session**:
```python
{
'user_id': 1, # Admin ID
'user_type': 'admin',
'_fresh': True, # For Flask-Login compatibility (future)
'_permanent': True
}
```
**Participant Session**:
```python
{
'user_id': 123, # Participant ID
'user_type': 'participant',
'exchange_id': 456, # Exchange participant is authenticated for
'_fresh': True,
'_permanent': True
}
```
### Session Validation
**Before Request Handler**:
```python
@app.before_request
def load_session_user():
if 'user_id' in session and 'user_type' in session:
if session['user_type'] == 'admin':
g.admin = Admin.query.get(session['user_id'])
elif session['user_type'] == 'participant':
g.participant = Participant.query.get(session['user_id'])
g.exchange_id = session.get('exchange_id')
```
### Session Expiration
**Default Expiration**: 7 days from last activity
**Remember Me** (Admin only): 30 days from last activity
**Sliding Window**: Flask-Session automatically updates `last_activity` on each request
**Cleanup**: Flask-Session handles expired session cleanup automatically
---
## Flash Messages
Flash messages provide user feedback for authentication actions.
### Flash Message Types
**Success Messages**:
- Category: `'success'`
- Auto-dismiss: 5 seconds
- Examples:
- "Admin account created successfully!"
- "Welcome back!"
- "You have been logged out"
- "Check your email for a magic link!"
- "Welcome, {name}!"
**Error Messages**:
- Category: `'error'`
- Auto-dismiss: Manual (user must dismiss)
- Examples:
- "Invalid email or password"
- "Too many login attempts. Please try again in {minutes} minutes."
- "This magic link is invalid or has expired."
### Implementation
**Backend** (Flask):
```python
from flask import flash
# Success message
flash("Admin account created successfully!", "success")
# Error message
flash("Invalid email or password", "error")
```
**Frontend** (Jinja2 + JavaScript):
```html
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message flash-{{ category }}"
{% if category == 'success' %}data-auto-dismiss="5000"{% endif %}>
{{ message }}
<button class="flash-dismiss" aria-label="Dismiss">&times;</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<script>
// Auto-dismiss success messages after 5 seconds
document.querySelectorAll('[data-auto-dismiss]').forEach(el => {
const delay = parseInt(el.dataset.autoDismiss);
setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => el.remove(), 300);
}, delay);
});
// Manual dismiss for all messages
document.querySelectorAll('.flash-dismiss').forEach(btn => {
btn.addEventListener('click', () => {
const message = btn.parentElement;
message.style.opacity = '0';
setTimeout(() => message.remove(), 300);
});
});
</script>
```
---
## Authorization Patterns
### Route Protection Decorators
**Admin-Only Routes**:
```python
from functools import wraps
from flask import session, redirect, url_for, flash, g
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session or session.get('user_type') != 'admin':
flash("You must be logged in as admin to access this page.", "error")
return redirect(url_for('admin_login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
return render_template('admin/dashboard.html')
```
**Participant-Only Routes**:
```python
def participant_required(f):
@wraps(f)
def decorated_function(slug, *args, **kwargs):
if 'user_id' not in session or session.get('user_type') != 'participant':
flash("You must be logged in to access this page.", "error")
return redirect(url_for('exchange_auth', slug=slug))
# Verify participant is authenticated for this exchange
exchange = Exchange.query.filter_by(slug=slug).first_or_404()
if session.get('exchange_id') != exchange.id:
flash("You are not authorized to access this exchange.", "error")
return redirect(url_for('exchange_auth', slug=slug))
return f(slug, *args, **kwargs)
return decorated_function
@app.route('/exchange/<slug>/dashboard')
@participant_required
def participant_dashboard(slug):
return render_template('participant/dashboard.html')
```
### Setup Requirement Check
**Before Request Handler**:
```python
@app.before_request
def check_setup_required():
# Skip check for setup route and static files
if request.endpoint in ['setup', 'static']:
return
# Check if admin exists
if app.config.get('REQUIRES_SETUP'):
admin_count = Admin.query.count()
if admin_count == 0:
return redirect(url_for('setup'))
else:
app.config['REQUIRES_SETUP'] = False
```
---
## Security Considerations
### Password Security
**Hashing Algorithm**: bcrypt with cost factor 12
**Minimum Password Length**: 12 characters
**Password Validation**:
- Enforced at form level (Flask-WTF)
- No complexity requirements (length is primary security measure)
**Implementation**:
```python
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt(app)
# Hash password
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
# Verify password
bcrypt.check_password_hash(admin.password_hash, password)
```
### CSRF Protection
**Implementation**: Flask-WTF automatic CSRF protection
**All Forms**: Include CSRF token automatically via `{{ form.csrf_token }}`
**All POST Routes**: Validate CSRF token automatically via Flask-WTF
**Exempt Routes**: None (all state-changing operations require CSRF token)
### Rate Limiting
**Storage**: SQLite `rate_limit` table
**Policies**:
- Admin login: 5 attempts / 15 minutes / email
- Magic link request: 3 requests / hour / email
**Bypass**: None (applies to all users including admin)
**Cleanup**: Expired entries purged daily via background job
### Session Security
**Cookie Settings**:
- `HttpOnly`: True (prevent XSS)
- `Secure`: True in production (HTTPS only)
- `SameSite`: Lax (CSRF protection)
**Session Signing**: Enabled via `SESSION_USE_SIGNER`
**Secret Key**: Loaded from environment variable `SECRET_KEY`
### Magic Link Security
**Token Generation**: `secrets.token_urlsafe(32)` (cryptographically secure)
**Token Storage**: SHA-256 hash only (original token never stored)
**Token Expiration**: 1 hour from creation
**Single Use**: Marked as used immediately upon verification
**Email Enumeration Prevention**: Generic success message for all email submissions
### Timing Attack Prevention
**Password Verification**: bcrypt naturally resistant to timing attacks
**Magic Link Lookup**: Use constant-time comparison for token hashes (handled by database query)
---
## Error Handling
### Authentication Errors
**Invalid Credentials**:
- HTTP Status: 200 (re-render form)
- Flash Message: "Invalid email or password"
- Rate Limit: Increment counter
**Rate Limit Exceeded**:
- HTTP Status: 429 Too Many Requests
- Flash Message: "Too many {action} attempts. Please try again in {minutes} minutes."
- Behavior: Re-render form with error
**Expired Magic Link**:
- HTTP Status: 200 (redirect to login)
- Flash Message: "This magic link is invalid or has expired."
- Behavior: Redirect to exchange login page
**CSRF Validation Failure**:
- HTTP Status: 400 Bad Request
- Flash Message: "Security validation failed. Please try again."
- Behavior: Redirect to form
### Session Errors
**Session Expired**:
- HTTP Status: 302 (redirect to login)
- Flash Message: "Your session has expired. Please log in again."
- Behavior: Redirect to appropriate login page
**Invalid Session**:
- HTTP Status: 302 (redirect to login)
- Flash Message: "You must be logged in to access this page."
- Behavior: Redirect to appropriate login page
---
## Testing Considerations
### Unit Tests
**Setup Flow**:
- Test admin creation with valid data
- Test duplicate admin prevention
- Test auto-login after setup
- Test setup route 404 when admin exists
**Admin Login**:
- Test successful login with valid credentials
- Test failed login with invalid credentials
- Test rate limiting after 5 failed attempts
- Test rate limit reset after successful login
- Test remember_me session duration
**Magic Link**:
- Test magic link generation for valid participant
- Test rate limiting after 3 requests
- Test token verification with valid token
- Test token expiration after 1 hour
- Test single-use token enforcement
### Integration Tests
**Full Authentication Flows**:
- Test setup → login → logout flow
- Test magic link request → email → verify → dashboard flow
- Test concurrent session handling
- Test session persistence across requests
### Security Tests
**CSRF Protection**:
- Test all POST routes require valid CSRF token
- Test CSRF token validation
**Rate Limiting**:
- Test rate limits are enforced correctly
- Test rate limit window expiration
**Session Security**:
- Test session cookie settings (HttpOnly, Secure, SameSite)
- Test session expiration
---
## Future Enhancements
Potential improvements for future versions:
1. **Two-Factor Authentication**: TOTP for admin login
2. **Password Reset**: Admin password reset via email
3. **Session Management UI**: Admin view of active sessions
4. **Account Lockout**: Temporary lockout after repeated failed logins
5. **Audit Logging**: Track all authentication events
6. **Multiple Admins**: Support for multiple admin accounts with roles
7. **OAuth Integration**: Social login options for participants
These enhancements are out of scope for v0.1.0.
---
## References
- [Flask-Session Documentation](https://flask-session.readthedocs.io/)
- [Flask-WTF Documentation](https://flask-wtf.readthedocs.io/)
- [bcrypt Documentation](https://github.com/pyca/bcrypt/)
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
- [Data Model v0.1.0](../data-model.md)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,732 @@
# Matching Component Design - v0.1.0
**Version**: 0.1.0
**Date**: 2025-12-22
**Status**: Initial Design
## Introduction
This document defines the Secret Santa matching algorithm for Sneaky Klaus. The matching algorithm is responsible for assigning each participant a recipient while respecting exclusion rules and following Secret Santa best practices.
## Requirements
### Functional Requirements
1. **One-to-One Assignment**: Each participant gives exactly one gift and receives exactly one gift
2. **No Self-Matching**: No participant is assigned to themselves
3. **Exclusion Compliance**: All exclusion rules must be honored
4. **Randomization**: Assignments must be unpredictable and fair
5. **Single Cycle Preferred**: When possible, create a single cycle (A→B→C→...→Z→A)
6. **Validation**: Detect impossible matching scenarios before attempting
### Non-Functional Requirements
1. **Performance**: Complete matching in <1 second for up to 100 participants
2. **Reliability**: Deterministic failure detection (no random timeouts)
3. **Transparency**: Clear error messages when matching fails
4. **Testability**: Algorithm must be unit-testable with reproducible results
## Algorithm Overview
The matching algorithm uses a **graph-based approach with randomized cycle generation**.
### High-Level Flow
```mermaid
flowchart TD
Start([Trigger Matching]) --> Validate[Validate Preconditions]
Validate -->|Invalid| Error1[Return Error: Validation Failed]
Validate -->|Valid| BuildGraph[Build Assignment Graph]
BuildGraph --> CheckFeasibility[Check Matching Feasibility]
CheckFeasibility -->|Impossible| Error2[Return Error: Impossible to Match]
CheckFeasibility -->|Possible| Attempt[Attempt to Find Valid Cycle]
Attempt --> MaxAttempts{Max attempts<br/>reached?}
MaxAttempts -->|Yes| Error3[Return Error: Could Not Find Match]
MaxAttempts -->|No| GenerateCycle[Generate Random Cycle]
GenerateCycle --> ValidateCycle{Cycle valid?}
ValidateCycle -->|No| MaxAttempts
ValidateCycle -->|Yes| CreateMatches[Create Match Records]
CreateMatches --> SendNotifications[Send Notifications]
SendNotifications --> Success([Matching Complete])
Error1 --> End([End])
Error2 --> End
Error3 --> End
Success --> End
```
## Precondition Validation
Before attempting matching, validate the following:
### Validation Checks
```python
def validate_matching_preconditions(exchange_id: int) -> ValidationResult:
"""
Validate that exchange is ready for matching.
Returns:
ValidationResult with is_valid and error_message
"""
checks = [
check_exchange_state(exchange_id),
check_minimum_participants(exchange_id),
check_no_withdrawn_participants(exchange_id),
check_graph_connectivity(exchange_id)
]
for check in checks:
if not check.is_valid:
return check
return ValidationResult(is_valid=True)
```
### Check 1: Exchange State
**Rule**: Exchange must be in "registration_closed" state
**Error Message**: "Exchange is not ready for matching. Please close registration first."
**Implementation**:
```python
def check_exchange_state(exchange_id: int) -> ValidationResult:
exchange = Exchange.query.get(exchange_id)
if exchange.state != ExchangeState.REGISTRATION_CLOSED:
return ValidationResult(
is_valid=False,
error_message="Exchange is not ready for matching. Please close registration first."
)
return ValidationResult(is_valid=True)
```
### Check 2: Minimum Participants
**Rule**: At least 3 non-withdrawn participants required
**Error Message**: "At least 3 participants are required for matching. Current count: {count}"
**Implementation**:
```python
def check_minimum_participants(exchange_id: int) -> ValidationResult:
participants = Participant.query.filter_by(
exchange_id=exchange_id,
withdrawn_at=None
).all()
if len(participants) < 3:
return ValidationResult(
is_valid=False,
error_message=f"At least 3 participants are required for matching. Current count: {len(participants)}"
)
return ValidationResult(is_valid=True)
```
### Check 3: Graph Connectivity
**Rule**: Exclusion rules must not make matching impossible
**Error Message**: Specific message based on connectivity issue
**Implementation**:
```python
def check_graph_connectivity(exchange_id: int) -> ValidationResult:
"""
Check if a valid Hamiltonian cycle is theoretically possible.
This doesn't guarantee a cycle exists, but rules out obvious impossibilities.
"""
participants = get_active_participants(exchange_id)
exclusions = get_exclusions(exchange_id)
# Build graph where edge (A, B) exists if A can give to B
graph = build_assignment_graph(participants, exclusions)
# Check 1: Each participant must have at least one possible recipient
for participant in participants:
possible_recipients = graph.get_outgoing_edges(participant.id)
if len(possible_recipients) == 0:
return ValidationResult(
is_valid=False,
error_message=f"Participant '{participant.name}' has no valid recipients. Please adjust exclusion rules."
)
# Check 2: Each participant must be a possible recipient for at least one other
for participant in participants:
possible_givers = graph.get_incoming_edges(participant.id)
if len(possible_givers) == 0:
return ValidationResult(
is_valid=False,
error_message=f"Participant '{participant.name}' cannot receive from anyone. Please adjust exclusion rules."
)
# Check 3: Detect common impossible scenarios
# Example: In a group of 3, if A excludes B, B excludes C, C excludes A,
# no valid cycle exists
if not has_potential_hamiltonian_cycle(graph):
return ValidationResult(
is_valid=False,
error_message="The current exclusion rules make a valid assignment impossible. Please reduce the number of exclusions."
)
return ValidationResult(is_valid=True)
```
## Assignment Graph Construction
### Graph Representation
Build a directed graph where:
- **Nodes**: Participants
- **Edges**: Valid assignments (A→B means A can give to B)
**Edge exists if**:
- A ≠ B (no self-matching)
- No exclusion rule prevents A→B
### Implementation
```python
class AssignmentGraph:
"""Directed graph representing valid gift assignments."""
def __init__(self, participants: list[Participant], exclusions: list[ExclusionRule]):
self.nodes = {p.id: p for p in participants}
self.edges = {} # {giver_id: [receiver_id, ...]}
self._build_graph(participants, exclusions)
def _build_graph(self, participants, exclusions):
"""Build adjacency list with exclusion rules applied."""
# Build exclusion lookup (bidirectional)
excluded_pairs = set()
for exclusion in exclusions:
excluded_pairs.add((exclusion.participant_a_id, exclusion.participant_b_id))
excluded_pairs.add((exclusion.participant_b_id, exclusion.participant_a_id))
# Build edges
for giver in participants:
self.edges[giver.id] = []
for receiver in participants:
# Can assign if: not self and not excluded
if giver.id != receiver.id and (giver.id, receiver.id) not in excluded_pairs:
self.edges[giver.id].append(receiver.id)
def get_outgoing_edges(self, node_id: int) -> list[int]:
"""Get all possible recipients for a giver."""
return self.edges.get(node_id, [])
def get_incoming_edges(self, node_id: int) -> list[int]:
"""Get all possible givers for a receiver."""
incoming = []
for giver_id, receivers in self.edges.items():
if node_id in receivers:
incoming.append(giver_id)
return incoming
```
## Cycle Generation Algorithm
### Strategy: Randomized Hamiltonian Cycle Search
Goal: Find a Hamiltonian cycle (visits each node exactly once) in the assignment graph.
**Why Single Cycle?**
- Ensures everyone gives and receives exactly once
- Prevents orphaned participants or small isolated loops
- Traditional Secret Santa structure
### Algorithm: Randomized Backtracking with Early Termination
```python
def generate_random_cycle(graph: AssignmentGraph, max_attempts: int = 100) -> Optional[list[tuple[int, int]]]:
"""
Attempt to find a valid Hamiltonian cycle.
Args:
graph: Assignment graph with nodes and edges
max_attempts: Maximum number of randomized attempts
Returns:
List of (giver_id, receiver_id) tuples representing the cycle,
or None if no cycle found within max_attempts
"""
nodes = list(graph.nodes.keys())
for attempt in range(max_attempts):
# Randomize starting point and node order for variety
random.shuffle(nodes)
start_node = nodes[0]
cycle = _backtrack_cycle(graph, start_node, nodes, [], set())
if cycle is not None:
return cycle
return None
def _backtrack_cycle(
graph: AssignmentGraph,
current_node: int,
all_nodes: list[int],
path: list[int],
visited: set[int]
) -> Optional[list[tuple[int, int]]]:
"""
Recursive backtracking to find Hamiltonian cycle.
Args:
current_node: Current node being processed
all_nodes: All nodes in graph
path: Current path taken
visited: Set of visited nodes
Returns:
List of edges forming cycle, or None if no cycle from this path
"""
# Add current node to path
path.append(current_node)
visited.add(current_node)
# Base case: All nodes visited
if len(visited) == len(all_nodes):
# Check if we can return to start (complete the cycle)
start_node = path[0]
if start_node in graph.get_outgoing_edges(current_node):
# Success! Build edge list
edges = []
for i in range(len(path)):
giver = path[i]
receiver = path[(i + 1) % len(path)] # Wrap around for cycle
edges.append((giver, receiver))
return edges
else:
# Can't complete cycle, backtrack
path.pop()
visited.remove(current_node)
return None
# Recursive case: Try each unvisited neighbor
neighbors = graph.get_outgoing_edges(current_node)
random.shuffle(neighbors) # Randomize for variety
for neighbor in neighbors:
if neighbor not in visited:
result = _backtrack_cycle(graph, neighbor, all_nodes, path, visited)
if result is not None:
return result
# No valid path found, backtrack
path.pop()
visited.remove(current_node)
return None
```
### Algorithm Complexity
**Time Complexity**:
- Worst case: O(n!) for Hamiltonian cycle problem (NP-complete)
- In practice: O(n²) to O(n³) for typical Secret Santa scenarios
- Max attempts limit prevents excessive computation
**Space Complexity**: O(n) for recursion stack and visited set
### Why Randomization?
1. **Fairness**: Each valid assignment has equal probability
2. **Unpredictability**: Prevents gaming the system
3. **Variety**: Re-matching produces different results
## Validation & Error Handling
### Cycle Validation
After generating a cycle, validate it before creating database records:
```python
def validate_cycle(cycle: list[tuple[int, int]], graph: AssignmentGraph) -> ValidationResult:
"""
Validate that cycle is valid.
Checks:
1. Each node appears exactly once as giver
2. Each node appears exactly once as receiver
3. All edges exist in graph (no exclusions violated)
4. No self-assignments
"""
givers = set()
receivers = set()
for giver_id, receiver_id in cycle:
# Check for duplicates
if giver_id in givers:
return ValidationResult(is_valid=False, error_message=f"Duplicate giver: {giver_id}")
if receiver_id in receivers:
return ValidationResult(is_valid=False, error_message=f"Duplicate receiver: {receiver_id}")
givers.add(giver_id)
receivers.add(receiver_id)
# Check no self-assignment
if giver_id == receiver_id:
return ValidationResult(is_valid=False, error_message="Self-assignment detected")
# Check edge exists (no exclusion violated)
if receiver_id not in graph.get_outgoing_edges(giver_id):
return ValidationResult(is_valid=False, error_message=f"Invalid assignment: {giver_id}{receiver_id}")
# Check all nodes present
if givers != set(graph.nodes.keys()) or receivers != set(graph.nodes.keys()):
return ValidationResult(is_valid=False, error_message="Not all participants included in cycle")
return ValidationResult(is_valid=True)
```
### Error Scenarios
| Scenario | Detection | Error Message |
|----------|-----------|---------------|
| Too few participants | Precondition check | "At least 3 participants required" |
| Participant isolated by exclusions | Graph connectivity check | "Participant '{name}' has no valid recipients" |
| Too many exclusions | Graph connectivity check | "Current exclusion rules make matching impossible" |
| Cannot find cycle | Max attempts reached | "Unable to find valid assignment. Try reducing exclusions." |
| Invalid state | Precondition check | "Exchange is not ready for matching" |
## Database Transaction
Matching operation must be atomic (all-or-nothing):
```python
def execute_matching(exchange_id: int) -> MatchingResult:
"""
Execute complete matching operation within transaction.
Returns:
MatchingResult with success status, matches, or error message
"""
from sqlalchemy import orm
# Begin transaction
with db.session.begin_nested():
try:
# 1. Validate preconditions
validation = validate_matching_preconditions(exchange_id)
if not validation.is_valid:
return MatchingResult(success=False, error=validation.error_message)
# 2. Get participants and exclusions
participants = get_active_participants(exchange_id)
exclusions = get_exclusions(exchange_id)
# 3. Build graph
graph = AssignmentGraph(participants, exclusions)
# 4. Generate cycle
cycle = generate_random_cycle(graph, max_attempts=100)
if cycle is None:
return MatchingResult(
success=False,
error="Unable to find valid assignment. Please reduce exclusion rules or add more participants."
)
# 5. Validate cycle
validation = validate_cycle(cycle, graph)
if not validation.is_valid:
return MatchingResult(success=False, error=validation.error_message)
# 6. Create match records
matches = []
for giver_id, receiver_id in cycle:
match = Match(
exchange_id=exchange_id,
giver_id=giver_id,
receiver_id=receiver_id
)
db.session.add(match)
matches.append(match)
# 7. Update exchange state
exchange = Exchange.query.get(exchange_id)
exchange.state = ExchangeState.MATCHED
# Commit nested transaction
db.session.commit()
return MatchingResult(success=True, matches=matches)
except Exception as e:
db.session.rollback()
logger.error(f"Matching failed for exchange {exchange_id}: {str(e)}")
return MatchingResult(
success=False,
error="An unexpected error occurred during matching. Please try again."
)
```
## Re-Matching
When admin triggers re-match, all existing matches must be cleared:
```python
def execute_rematching(exchange_id: int) -> MatchingResult:
"""
Clear existing matches and generate new assignments.
"""
with db.session.begin_nested():
try:
# 1. Validate exchange is in matched state
exchange = Exchange.query.get(exchange_id)
if exchange.state != ExchangeState.MATCHED:
return MatchingResult(success=False, error="Exchange is not in matched state")
# 2. Delete existing matches
Match.query.filter_by(exchange_id=exchange_id).delete()
# 3. Revert state to registration_closed
exchange.state = ExchangeState.REGISTRATION_CLOSED
db.session.flush()
# 4. Run matching again
result = execute_matching(exchange_id)
if result.success:
db.session.commit()
else:
db.session.rollback()
return result
except Exception as e:
db.session.rollback()
logger.error(f"Re-matching failed for exchange {exchange_id}: {str(e)}")
return MatchingResult(success=False, error="Re-matching failed. Please try again.")
```
## Integration with Notification Service
After successful matching, trigger notifications:
```python
def complete_matching_workflow(exchange_id: int) -> WorkflowResult:
"""
Complete matching and send notifications.
"""
# Execute matching
matching_result = execute_matching(exchange_id)
if not matching_result.success:
return WorkflowResult(success=False, error=matching_result.error)
# Send notifications to all participants
try:
notification_service = NotificationService()
notification_service.send_match_notifications(exchange_id)
# Notify admin (if enabled)
notification_service.send_admin_notification(
exchange_id,
NotificationType.MATCHING_COMPLETE
)
return WorkflowResult(success=True)
except Exception as e:
logger.error(f"Failed to send match notifications for exchange {exchange_id}: {str(e)}")
# Matching succeeded but notification failed
# Return success but log the notification failure
return WorkflowResult(
success=True,
warning="Matching complete but some notifications failed to send. Please check email service."
)
```
## Testing Strategy
### Unit Tests
```python
class TestMatchingAlgorithm(unittest.TestCase):
def test_minimum_viable_matching(self):
"""Test matching with 3 participants, no exclusions."""
participants = create_test_participants(3)
exclusions = []
graph = AssignmentGraph(participants, exclusions)
cycle = generate_random_cycle(graph)
self.assertIsNotNone(cycle)
self.assertEqual(len(cycle), 3)
validation = validate_cycle(cycle, graph)
self.assertTrue(validation.is_valid)
def test_matching_with_exclusions(self):
"""Test matching with valid exclusions."""
participants = create_test_participants(5)
exclusions = [
create_exclusion(participants[0], participants[1])
]
graph = AssignmentGraph(participants, exclusions)
cycle = generate_random_cycle(graph)
self.assertIsNotNone(cycle)
# Verify exclusion is respected
for giver_id, receiver_id in cycle:
self.assertNotEqual((giver_id, receiver_id), (participants[0].id, participants[1].id))
def test_impossible_matching_detected(self):
"""Test that impossible matching is detected in validation."""
# Create 3 participants where each excludes the next
# A excludes B, B excludes C, C excludes A
# No Hamiltonian cycle possible
participants = create_test_participants(3)
exclusions = [
create_exclusion(participants[0], participants[1]),
create_exclusion(participants[1], participants[2]),
create_exclusion(participants[2], participants[0])
]
graph = AssignmentGraph(participants, exclusions)
validation = check_graph_connectivity_with_graph(graph)
self.assertFalse(validation.is_valid)
def test_no_self_matching(self):
"""Ensure no participant is matched to themselves."""
participants = create_test_participants(10)
exclusions = []
graph = AssignmentGraph(participants, exclusions)
cycle = generate_random_cycle(graph)
for giver_id, receiver_id in cycle:
self.assertNotEqual(giver_id, receiver_id)
def test_everyone_gives_and_receives_once(self):
"""Ensure each participant gives and receives exactly once."""
participants = create_test_participants(10)
exclusions = []
graph = AssignmentGraph(participants, exclusions)
cycle = generate_random_cycle(graph)
givers = set(giver for giver, _ in cycle)
receivers = set(receiver for _, receiver in cycle)
self.assertEqual(len(givers), 10)
self.assertEqual(len(receivers), 10)
self.assertEqual(givers, {p.id for p in participants})
self.assertEqual(receivers, {p.id for p in participants})
```
### Integration Tests
```python
class TestMatchingIntegration(TestCase):
def test_full_matching_workflow(self):
"""Test complete matching workflow from database to notifications."""
# Setup
exchange = create_test_exchange()
participants = [create_test_participant(exchange) for _ in range(5)]
# Execute
result = complete_matching_workflow(exchange.id)
# Assert
self.assertTrue(result.success)
exchange = Exchange.query.get(exchange.id)
self.assertEqual(exchange.state, ExchangeState.MATCHED)
matches = Match.query.filter_by(exchange_id=exchange.id).all()
self.assertEqual(len(matches), 5)
def test_rematching_clears_old_matches(self):
"""Test that re-matching replaces old assignments."""
# Setup
exchange = create_matched_exchange_with_5_participants()
old_matches = Match.query.filter_by(exchange_id=exchange.id).all()
old_match_ids = {m.id for m in old_matches}
# Execute
result = execute_rematching(exchange.id)
# Assert
self.assertTrue(result.success)
new_matches = Match.query.filter_by(exchange_id=exchange.id).all()
new_match_ids = {m.id for m in new_matches}
# Old match records should be deleted
self.assertEqual(len(old_match_ids.intersection(new_match_ids)), 0)
```
## Performance Considerations
### Expected Performance
| Participants | Exclusions | Expected Time | Notes |
|--------------|------------|---------------|-------|
| 3-10 | 0-5 | <10ms | Instant |
| 10-50 | 0-20 | <100ms | Very fast |
| 50-100 | 0-50 | <500ms | Fast enough |
| 100+ | Any | Variable | May exceed max attempts |
### Optimization Strategies
1. **Graph Pruning**: Remove impossible edges early
2. **Heuristic Ordering**: Start with most constrained nodes
3. **Adaptive Max Attempts**: Increase attempts for larger groups
4. **Fallback to Multiple Cycles**: If single cycle fails, allow 2-3 small cycles
### Max Attempts Configuration
```python
def get_max_attempts(num_participants: int) -> int:
"""Adaptive max attempts based on participant count."""
if num_participants <= 10:
return 50
elif num_participants <= 50:
return 100
else:
return 200
```
## Security Considerations
### Randomization Source
- Use `secrets.SystemRandom()` for cryptographic randomness
- Prevents predictable assignments
- Important for preventing manipulation
```python
import secrets
random_generator = secrets.SystemRandom()
def shuffle(items: list):
"""Cryptographically secure shuffle."""
random_generator.shuffle(items)
```
### Match Confidentiality
- Matches only visible to:
- Giver (sees their own recipient)
- Admin (for troubleshooting)
- Never expose matches in logs
- Database queries filtered by permissions
## Future Enhancements
Potential improvements for future versions:
1. **Multi-Cycle Support**: Allow multiple small cycles if single cycle impossible
2. **Preference Weighting**: Allow participants to indicate preferences
3. **Historical Avoidance**: Avoid repeating matches from previous years
4. **Couple Pairing**: Assign couples to same family/group
5. **Performance Metrics**: Track matching time and success rate
6. **Manual Override**: Allow admin to manually adjust specific assignments
## References
- [Hamiltonian Cycle Problem](https://en.wikipedia.org/wiki/Hamiltonian_path_problem)
- [Graph Theory Basics](https://en.wikipedia.org/wiki/Graph_theory)
- [Backtracking Algorithm](https://en.wikipedia.org/wiki/Backtracking)
- [Data Model Specification](../data-model.md)
- [API Specification](../api-spec.md)

View File

@@ -0,0 +1,979 @@
# Notifications Component Design - v0.1.0
**Version**: 0.1.0
**Date**: 2025-12-22
**Status**: Initial Design
## Introduction
This document defines the email notification system for Sneaky Klaus. The notification service handles all transactional and reminder emails sent to participants and administrators using Resend as the email delivery provider.
## Requirements
### Functional Requirements
1. **Transactional Emails**: Send immediate emails in response to user actions
2. **Reminder Emails**: Send scheduled reminder emails before exchange date
3. **Admin Notifications**: Notify admin of important exchange events (opt-in)
4. **Magic Link Delivery**: Include authentication tokens in emails
5. **Template Management**: Maintain consistent branded email templates
6. **Error Handling**: Gracefully handle email delivery failures
### Non-Functional Requirements
1. **Reliability**: Guarantee delivery or retry on failure
2. **Performance**: Send emails asynchronously without blocking requests
3. **Auditability**: Log all email send attempts
4. **Deliverability**: Follow email best practices (SPF, DKIM, unsubscribe links)
## Email Types
### Participant Emails
| Email Type | Trigger | Recipient | Time-Sensitive |
|------------|---------|-----------|----------------|
| Registration Confirmation | Participant registers | Participant | Yes (immediate) |
| Magic Link | Participant requests access | Participant | Yes (immediate) |
| Match Notification | Matching complete | All participants | Yes (immediate) |
| Reminder Email | Scheduled (pre-exchange) | Opted-in participants | Yes (scheduled) |
| Withdrawal Confirmation | Participant withdraws | Participant | Yes (immediate) |
### Admin Emails
| Email Type | Trigger | Recipient | Time-Sensitive |
|------------|---------|-----------|----------------|
| Password Reset | Admin requests reset | Admin | Yes (immediate) |
| New Registration | Participant registers | Admin | No (opt-in) |
| Participant Withdrawal | Participant withdraws | Admin | No (opt-in) |
| Matching Complete | Matching succeeds | Admin | No (opt-in) |
| Data Purge Warning | 7 days before purge | Admin | No |
## Notification Service Architecture
```mermaid
flowchart TB
subgraph "Application Layer"
Route[Route Handler]
Service[Business Logic]
end
subgraph "Notification Service"
NS[NotificationService]
TemplateEngine[Template Renderer]
EmailQueue[Email Queue]
end
subgraph "External Services"
Resend[Resend API]
end
subgraph "Storage"
DB[(Database)]
Templates[Email Templates]
end
Route --> Service
Service --> NS
NS --> TemplateEngine
TemplateEngine --> Templates
NS --> EmailQueue
EmailQueue --> Resend
NS --> DB
Resend --> DB
style Resend fill:#f9f,stroke:#333
style Templates fill:#bfb,stroke:#333
```
## Implementation Structure
### Service Class
```python
class NotificationService:
"""
Centralized service for all email notifications.
"""
def __init__(self, resend_client: ResendClient = None):
self.resend = resend_client or ResendClient(api_key=get_resend_api_key())
self.template_renderer = EmailTemplateRenderer()
self.logger = logging.getLogger(__name__)
# Participant Emails
def send_registration_confirmation(self, participant_id: int) -> EmailResult
def send_magic_link(self, participant_id: int, token: str) -> EmailResult
def send_match_notification(self, participant_id: int) -> EmailResult
def send_reminder_email(self, participant_id: int) -> EmailResult
def send_withdrawal_confirmation(self, participant_id: int) -> EmailResult
# Admin Emails
def send_password_reset(self, admin_email: str, token: str) -> EmailResult
def send_admin_notification(self, exchange_id: int, notification_type: NotificationType) -> EmailResult
def send_data_purge_warning(self, exchange_id: int) -> EmailResult
# Batch Operations
def send_match_notifications_batch(self, exchange_id: int) -> BatchEmailResult
# Internal Methods
def _send_email(self, email_request: EmailRequest) -> EmailResult
def _log_email_send(self, email_request: EmailRequest, result: EmailResult)
```
## Email Templates
### Template Structure
All email templates use Jinja2 with HTML and plain text versions:
**Directory Structure**:
```
templates/emails/
├── base.html # Base template with header/footer
├── base.txt # Plain text base
├── participant/
│ ├── registration_confirmation.html
│ ├── registration_confirmation.txt
│ ├── magic_link.html
│ ├── magic_link.txt
│ ├── match_notification.html
│ ├── match_notification.txt
│ ├── reminder.html
│ ├── reminder.txt
│ └── withdrawal_confirmation.html
│ └── withdrawal_confirmation.txt
└── admin/
├── password_reset.html
├── password_reset.txt
├── new_registration.html
├── new_registration.txt
├── participant_withdrawal.html
├── participant_withdrawal.txt
├── matching_complete.html
├── matching_complete.txt
├── data_purge_warning.html
└── data_purge_warning.txt
```
### Base Template
**base.html**:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Sneaky Klaus{% endblock %}</title>
<style>
/* Inline CSS for email client compatibility */
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #d32f2f; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background-color: #f9f9f9; }
.button { display: inline-block; padding: 12px 24px; background-color: #d32f2f; color: white; text-decoration: none; border-radius: 4px; }
.footer { text-align: center; font-size: 12px; color: #666; padding: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎅 Sneaky Klaus</h1>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="footer">
<p>You received this email because you're part of a Sneaky Klaus Secret Santa exchange.</p>
{% block unsubscribe %}{% endblock %}
<p>&copy; {{ current_year }} Sneaky Klaus</p>
</div>
</div>
</body>
</html>
```
**base.txt**:
```text
SNEAKY KLAUS
=============
{% block content %}{% endblock %}
---
You received this email because you're part of a Sneaky Klaus Secret Santa exchange.
{% block unsubscribe %}{% endblock %}
© {{ current_year }} Sneaky Klaus
```
## Participant Email Specifications
### 1. Registration Confirmation
**Trigger**: Immediately after participant registration
**Subject**: "Welcome to {exchange_name}!"
**Template Variables**:
```python
{
"participant_name": str,
"exchange_name": str,
"exchange_date": datetime,
"budget": str,
"magic_link_url": str,
"app_url": str
}
```
**Content** (HTML version):
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Welcome to {{ exchange_name }}!</h2>
<p>Hi {{ participant_name }},</p>
<p>You've successfully registered for the Secret Santa exchange!</p>
<p><strong>Exchange Details:</strong></p>
<ul>
<li><strong>Event Date:</strong> {{ exchange_date|format_date }}</li>
<li><strong>Gift Budget:</strong> {{ budget }}</li>
</ul>
<p>You can update your gift ideas or view participant information anytime using the link below:</p>
<p style="text-align: center;">
<a href="{{ magic_link_url }}" class="button">Access My Registration</a>
</p>
<p><small>This link will expire in 1 hour. You can request a new one anytime from the registration page.</small></p>
<p>When participants are matched, you'll receive another email with your Secret Santa assignment.</p>
<p>Happy gifting!</p>
{% endblock %}
```
**Plain Text Version**: Similar content without HTML formatting
---
### 2. Magic Link
**Trigger**: Participant requests access to registration
**Subject**: "Access Your Sneaky Klaus Registration"
**Template Variables**:
```python
{
"participant_name": str,
"exchange_name": str,
"magic_link_url": str,
"expiration_minutes": int # 60
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Access Your Registration</h2>
<p>Hi {{ participant_name }},</p>
<p>You requested access to your registration for <strong>{{ exchange_name }}</strong>.</p>
<p style="text-align: center;">
<a href="{{ magic_link_url }}" class="button">Access My Registration</a>
</p>
<p><small>This link will expire in {{ expiration_minutes }} minutes and can only be used once.</small></p>
<p>If you didn't request this link, you can safely ignore this email.</p>
{% endblock %}
```
---
### 3. Match Notification
**Trigger**: Matching complete (sent to all participants)
**Subject**: "Your Secret Santa Assignment for {exchange_name}"
**Template Variables**:
```python
{
"participant_name": str,
"exchange_name": str,
"exchange_date": datetime,
"budget": str,
"recipient_name": str,
"recipient_gift_ideas": str,
"magic_link_url": str,
"participant_count": int
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Your Secret Santa Assignment</h2>
<p>Hi {{ participant_name }},</p>
<p>Participants have been matched for <strong>{{ exchange_name }}</strong>! 🎁</p>
<div style="background-color: white; padding: 20px; border-radius: 8px; border-left: 4px solid #d32f2f; margin: 20px 0;">
<h3 style="margin-top: 0;">You're buying for:</h3>
<p style="font-size: 18px; font-weight: bold; margin: 10px 0;">{{ recipient_name }}</p>
{% if recipient_gift_ideas %}
<p><strong>Gift Ideas:</strong></p>
<p style="white-space: pre-wrap;">{{ recipient_gift_ideas }}</p>
{% else %}
<p><em>No gift ideas provided yet.</em></p>
{% endif %}
</div>
<p><strong>Exchange Details:</strong></p>
<ul>
<li><strong>Gift Budget:</strong> {{ budget }}</li>
<li><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</li>
<li><strong>Total Participants:</strong> {{ participant_count }}</li>
</ul>
<p>You can view this information anytime by clicking the link below:</p>
<p style="text-align: center;">
<a href="{{ magic_link_url }}" class="button">View My Assignment</a>
</p>
<p><strong>Remember:</strong> Keep your assignment secret! The fun is in the surprise. 🤫</p>
<p>Happy shopping!</p>
{% endblock %}
```
---
### 4. Reminder Email
**Trigger**: Scheduled (based on admin configuration, e.g., 7 days, 3 days, 1 day before exchange)
**Subject**: "Reminder: {exchange_name} is {days_until} days away!"
**Template Variables**:
```python
{
"participant_name": str,
"exchange_name": str,
"exchange_date": datetime,
"days_until": int,
"recipient_name": str,
"recipient_gift_ideas": str,
"budget": str,
"magic_link_url": str
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Don't Forget! {{ exchange_name }} is Coming Up</h2>
<p>Hi {{ participant_name }},</p>
<p>This is a friendly reminder that <strong>{{ exchange_name }}</strong> is only <strong>{{ days_until }} day{{ 's' if days_until != 1 else '' }}</strong> away!</p>
<p>You're buying for: <strong>{{ recipient_name }}</strong></p>
{% if recipient_gift_ideas %}
<p><strong>Their Gift Ideas:</strong></p>
<p style="white-space: pre-wrap; background-color: white; padding: 15px; border-radius: 4px;">{{ recipient_gift_ideas }}</p>
{% endif %}
<p><strong>Gift Budget:</strong> {{ budget }}</p>
<p><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</p>
<p style="text-align: center;">
<a href="{{ magic_link_url }}" class="button">View Full Details</a>
</p>
<p>Happy shopping! 🎁</p>
{% endblock %}
{% block unsubscribe %}
<p><a href="{{ unsubscribe_url }}">Don't want reminders? Update your preferences</a></p>
{% endblock %}
```
---
### 5. Withdrawal Confirmation
**Trigger**: Participant withdraws from exchange
**Subject**: "You've withdrawn from {exchange_name}"
**Template Variables**:
```python
{
"participant_name": str,
"exchange_name": str
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Withdrawal Confirmed</h2>
<p>Hi {{ participant_name }},</p>
<p>You've successfully withdrawn from <strong>{{ exchange_name }}</strong>.</p>
<p>You will no longer receive any emails about this exchange.</p>
<p>If this was a mistake, please contact the exchange organizer to re-register.</p>
{% endblock %}
```
## Admin Email Specifications
### 1. Password Reset
**Trigger**: Admin requests password reset
**Subject**: "Password Reset Request - Sneaky Klaus"
**Template Variables**:
```python
{
"reset_link_url": str,
"expiration_minutes": int # 60
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Password Reset Request</h2>
<p>You requested a password reset for your Sneaky Klaus admin account.</p>
<p style="text-align: center;">
<a href="{{ reset_link_url }}" class="button">Reset Password</a>
</p>
<p><small>This link will expire in {{ expiration_minutes }} minutes and can only be used once.</small></p>
<p>If you didn't request this reset, you can safely ignore this email. Your password will not be changed.</p>
{% endblock %}
```
---
### 2. New Registration (Admin Notification)
**Trigger**: Participant registers (if admin has enabled this notification)
**Subject**: "New Participant in {exchange_name}"
**Template Variables**:
```python
{
"exchange_name": str,
"participant_name": str,
"participant_email": str,
"participant_count": int,
"max_participants": int,
"exchange_url": str
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>New Participant Registered</h2>
<p>A new participant has joined <strong>{{ exchange_name }}</strong>:</p>
<ul>
<li><strong>Name:</strong> {{ participant_name }}</li>
<li><strong>Email:</strong> {{ participant_email }}</li>
</ul>
<p><strong>Participant Count:</strong> {{ participant_count }} / {{ max_participants }}</p>
<p style="text-align: center;">
<a href="{{ exchange_url }}" class="button">View Exchange</a>
</p>
{% endblock %}
```
---
### 3. Participant Withdrawal (Admin Notification)
**Trigger**: Participant withdraws (if admin has enabled this notification)
**Subject**: "Participant Withdrew from {exchange_name}"
**Template Variables**:
```python
{
"exchange_name": str,
"participant_name": str,
"participant_count": int,
"exchange_url": str
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Participant Withdrew</h2>
<p><strong>{{ participant_name }}</strong> has withdrawn from <strong>{{ exchange_name }}</strong>.</p>
<p><strong>Remaining Participants:</strong> {{ participant_count }}</p>
<p style="text-align: center;">
<a href="{{ exchange_url }}" class="button">View Exchange</a>
</p>
{% endblock %}
```
---
### 4. Matching Complete (Admin Notification)
**Trigger**: Matching succeeds (if admin has enabled this notification)
**Subject**: "Matching Complete for {exchange_name}"
**Template Variables**:
```python
{
"exchange_name": str,
"participant_count": int,
"exchange_date": datetime,
"exchange_url": str
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Matching Complete! 🎉</h2>
<p>Participants have been successfully matched for <strong>{{ exchange_name }}</strong>.</p>
<p><strong>Details:</strong></p>
<ul>
<li><strong>Participants Matched:</strong> {{ participant_count }}</li>
<li><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</li>
</ul>
<p>All participants have been notified of their assignments via email.</p>
<p style="text-align: center;">
<a href="{{ exchange_url }}" class="button">View Exchange</a>
</p>
{% endblock %}
```
---
### 5. Data Purge Warning
**Trigger**: 7 days before exchange data is purged (30 days after completion)
**Subject**: "Data Purge Scheduled for {exchange_name}"
**Template Variables**:
```python
{
"exchange_name": str,
"purge_date": datetime,
"days_until_purge": int,
"exchange_url": str
}
```
**Content**:
```html
{% extends "emails/base.html" %}
{% block content %}
<h2>Data Purge Scheduled</h2>
<p>The exchange <strong>{{ exchange_name }}</strong> will be automatically deleted in <strong>{{ days_until_purge }} days</strong> ({{ purge_date|format_date }}).</p>
<p>All participant data, matches, and exchange details will be permanently removed as per the 30-day retention policy.</p>
<p>If you need to keep this data, please export it before the purge date.</p>
<p style="text-align: center;">
<a href="{{ exchange_url }}" class="button">View Exchange</a>
</p>
{% endblock %}
```
## Resend Integration
### Configuration
```python
import resend
class ResendClient:
"""Wrapper for Resend API."""
def __init__(self, api_key: str):
resend.api_key = api_key
self.from_email = "noreply@sneakyklaus.app" # Configured domain
self.from_name = "Sneaky Klaus"
def send_email(self, request: EmailRequest) -> EmailResult:
"""
Send email via Resend API.
Args:
request: EmailRequest with to, subject, html, text
Returns:
EmailResult with success status and message ID
"""
try:
params = {
"from": f"{self.from_name} <{self.from_email}>",
"to": [request.to_email],
"subject": request.subject,
"html": request.html_body,
"text": request.text_body,
}
# Optional: Add tags for tracking
if request.tags:
params["tags"] = request.tags
response = resend.Emails.send(params)
return EmailResult(
success=True,
message_id=response["id"],
timestamp=datetime.utcnow()
)
except resend.exceptions.ResendError as e:
logger.error(f"Resend API error: {str(e)}")
return EmailResult(
success=False,
error=str(e),
timestamp=datetime.utcnow()
)
except Exception as e:
logger.error(f"Unexpected error sending email: {str(e)}")
return EmailResult(
success=False,
error="Internal error",
timestamp=datetime.utcnow()
)
```
### Email Request Model
```python
@dataclass
class EmailRequest:
"""Email send request."""
to_email: str
subject: str
html_body: str
text_body: str
tags: Optional[dict] = None # For analytics/tracking
@dataclass
class EmailResult:
"""Email send result."""
success: bool
message_id: Optional[str] = None
error: Optional[str] = None
timestamp: datetime = field(default_factory=datetime.utcnow)
```
### Email Tags (Optional)
For analytics and troubleshooting:
```python
tags = {
"type": "match_notification",
"exchange_id": "123",
"environment": "production"
}
```
## Error Handling & Retries
### Retry Strategy
```python
def send_email_with_retry(request: EmailRequest, max_retries: int = 3) -> EmailResult:
"""
Send email with exponential backoff retry.
Args:
request: EmailRequest to send
max_retries: Maximum retry attempts
Returns:
EmailResult
"""
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
@retry(
stop=stop_after_attempt(max_retries),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(resend.exceptions.ResendError)
)
def _send():
return resend_client.send_email(request)
try:
return _send()
except Exception as e:
logger.error(f"Failed to send email after {max_retries} attempts: {str(e)}")
return EmailResult(success=False, error=f"Failed after {max_retries} retries")
```
### Failure Logging
All email send attempts logged to database for audit:
```python
class EmailLog(db.Model):
"""Audit log for email sends."""
id = db.Column(db.Integer, primary_key=True)
to_email = db.Column(db.String(255), nullable=False)
subject = db.Column(db.String(500), nullable=False)
email_type = db.Column(db.String(50), nullable=False)
success = db.Column(db.Boolean, nullable=False)
message_id = db.Column(db.String(255), nullable=True)
error = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
```
## Batch Email Sending
For sending to multiple recipients (e.g., match notifications):
```python
def send_match_notifications_batch(self, exchange_id: int) -> BatchEmailResult:
"""
Send match notifications to all participants in exchange.
Args:
exchange_id: Exchange ID
Returns:
BatchEmailResult with success count and failures
"""
participants = Participant.query.filter_by(
exchange_id=exchange_id,
withdrawn_at=None
).all()
results = []
failed = []
for participant in participants:
result = self.send_match_notification(participant.id)
results.append(result)
if not result.success:
failed.append({
"participant_id": participant.id,
"participant_email": participant.email,
"error": result.error
})
# Rate limit: Small delay between sends
time.sleep(0.1)
return BatchEmailResult(
total=len(participants),
successful=len([r for r in results if r.success]),
failed=len(failed),
failures=failed
)
```
## Testing
### Email Testing in Development
**Option 1: Resend Test Mode**
- Use Resend's test API key
- Emails sent to test mode, not delivered
**Option 2: MailHog / MailCatcher**
- Local SMTP server for testing
- View emails in web UI
**Option 3: Mock in Unit Tests**
```python
from unittest.mock import patch
class TestNotificationService(unittest.TestCase):
@patch('notification_service.ResendClient.send_email')
def test_send_registration_confirmation(self, mock_send):
mock_send.return_value = EmailResult(success=True, message_id="test-123")
service = NotificationService()
result = service.send_registration_confirmation(participant_id=1)
self.assertTrue(result.success)
mock_send.assert_called_once()
```
### Integration Tests
```python
class TestNotificationIntegration(TestCase):
def test_full_match_notification_workflow(self):
"""Test complete match notification workflow."""
# Setup
exchange = create_test_exchange()
participants = [create_test_participant(exchange) for _ in range(5)]
execute_matching(exchange.id)
# Execute
service = NotificationService()
result = service.send_match_notifications_batch(exchange.id)
# Assert
self.assertEqual(result.total, 5)
self.assertEqual(result.successful, 5)
self.assertEqual(result.failed, 0)
# Verify emails logged
logs = EmailLog.query.filter_by(email_type="match_notification").all()
self.assertEqual(len(logs), 5)
```
## Deliverability Best Practices
### SPF, DKIM, DMARC
Configure DNS records for Resend domain:
- **SPF**: Authorize Resend to send on your behalf
- **DKIM**: Sign emails cryptographically
- **DMARC**: Define policy for failed authentication
**Example DNS Configuration**:
```
TXT @ "v=spf1 include:_spf.resend.com ~all"
CNAME resend._domainkey resend.domainkey.resend.com
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:admin@example.com"
```
### Unsubscribe Links
For reminder emails, include unsubscribe link:
```python
unsubscribe_url = f"{app_url}/participant/exchange/{exchange_id}/edit"
```
Participants can disable reminders via profile edit.
### Email Content Best Practices
1. **Clear Subject Lines**: Descriptive and concise
2. **Plain Text Alternative**: Always include text version
3. **Inline CSS**: Email clients strip external stylesheets
4. **Mobile Responsive**: Use responsive design techniques
5. **Clear Call-to-Action**: Prominent buttons/links
6. **Avoid Spam Triggers**: No all-caps, excessive punctuation, spam keywords
## Performance Considerations
### Asynchronous Sending
For non-critical emails, send asynchronously:
```python
from threading import Thread
def send_email_async(email_request: EmailRequest):
"""Send email in background thread."""
thread = Thread(target=lambda: notification_service.send_email(email_request))
thread.start()
```
**Note**: For production, use proper background job queue (see background-jobs.md)
### Rate Limiting
Resend has rate limits (depends on plan):
- Free: 100 emails/day
- Paid: Higher limits
**Mitigation**:
- Batch operations with delays between sends
- Implement queue for large batches
- Monitor usage and implement backoff
## Security Considerations
### Token Inclusion
Magic links and password reset tokens:
- **URL Structure**: `{app_url}/auth/participant/magic/{token}`
- **Token Format**: 32-byte random, base64url encoded
- **Security**: Tokens hashed in database, original never stored
### Email Spoofing Prevention
- Use authenticated Resend domain
- Configure SPF/DKIM/DMARC
- Never allow user-controlled "from" addresses
### Sensitive Data
- **Never include**: Passwords, full tokens (only links)
- **Include only necessary**: Participant names, gift ideas (expected in context)
- **Audit log**: Track all emails sent
## Future Enhancements
1. **HTML Email Builder**: Visual template editor for admin
2. **Localization**: Multi-language email templates
3. **A/B Testing**: Test different email content for engagement
4. **Analytics**: Track open rates, click rates (Resend webhooks)
5. **Custom Branding**: Allow admin to customize email header/colors
6. **Email Queue Dashboard**: Admin view of pending/failed emails
## References
- [Resend Documentation](https://resend.com/docs)
- [Jinja2 Template Documentation](https://jinja.palletsprojects.com/)
- [Email Deliverability Best Practices](https://www.mailgun.com/blog/email/email-deliverability-best-practices/)
- [Data Model Specification](../data-model.md)
- [API Specification](../api-spec.md)

View File

@@ -0,0 +1,775 @@
# Data Model - v0.1.0
**Version**: 0.1.0
**Date**: 2025-12-22
**Status**: Initial Design
## Introduction
This document defines the complete database schema for Sneaky Klaus. The schema is designed for SQLite with SQLAlchemy ORM, optimized for read-heavy workloads with occasional writes, and structured to support all user stories in the product backlog.
**Note**: Session storage is managed by Flask-Session, which creates and manages its own session table. The custom Session table previously defined has been removed in favor of Flask-Session's implementation.
## Entity Relationship Diagram
```mermaid
erDiagram
Admin ||--o{ Exchange : creates
Exchange ||--o{ Participant : contains
Exchange ||--o{ ExclusionRule : defines
Exchange ||--o{ NotificationPreference : configures
Participant ||--o{ Match : "gives to"
Participant ||--o{ Match : "receives from"
Participant ||--o{ MagicToken : authenticates_with
Participant }o--o{ ExclusionRule : excluded_from
Exchange {
int id PK
string slug UK
string name
text description
string budget
int max_participants
datetime registration_close_date
datetime exchange_date
string timezone
string state
datetime created_at
datetime updated_at
datetime completed_at
}
Admin {
int id PK
string email UK
string password_hash
datetime created_at
datetime updated_at
}
Participant {
int id PK
int exchange_id FK
string name
string email
text gift_ideas
boolean reminder_enabled
datetime created_at
datetime updated_at
datetime withdrawn_at
}
Match {
int id PK
int exchange_id FK
int giver_id FK
int receiver_id FK
datetime created_at
}
ExclusionRule {
int id PK
int exchange_id FK
int participant_a_id FK
int participant_b_id FK
datetime created_at
}
MagicToken {
int id PK
string token_hash UK
string token_type
string email
int participant_id FK
int exchange_id FK
datetime created_at
datetime expires_at
datetime used_at
}
PasswordResetToken {
int id PK
string token_hash UK
string email
datetime created_at
datetime expires_at
datetime used_at
}
RateLimit {
int id PK
string key UK
int attempts
datetime window_start
datetime expires_at
}
NotificationPreference {
int id PK
int exchange_id FK
boolean new_registration
boolean participant_withdrawal
boolean matching_complete
datetime created_at
datetime updated_at
}
```
## Entity Definitions
### Admin
The administrator account for the entire installation. Only one admin exists per deployment.
**Table**: `admin`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `email` | VARCHAR(255) | UNIQUE, NOT NULL | Admin email address |
| `password_hash` | VARCHAR(255) | NOT NULL | bcrypt password hash |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Account creation timestamp |
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
**Indexes**:
- `idx_admin_email` on `email` (unique)
**Constraints**:
- Email format validation at application level
- Only one admin record should exist (enforced at application level)
**Notes**:
- Password hash uses bcrypt with cost factor 12
- `updated_at` automatically updated on any modification
---
### Exchange
Represents a single Secret Santa exchange event.
**Table**: `exchange`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `slug` | VARCHAR(12) | UNIQUE, NOT NULL | URL-safe identifier for exchange |
| `name` | VARCHAR(255) | NOT NULL | Exchange name/title |
| `description` | TEXT | NULLABLE | Optional description |
| `budget` | VARCHAR(100) | NOT NULL | Gift budget (e.g., "$20-30") |
| `max_participants` | INTEGER | NOT NULL, CHECK >= 3 | Maximum participant limit |
| `registration_close_date` | TIMESTAMP | NOT NULL | When registration ends |
| `exchange_date` | TIMESTAMP | NOT NULL | When gifts are exchanged |
| `timezone` | VARCHAR(50) | NOT NULL | Timezone for dates (e.g., "America/New_York") |
| `state` | VARCHAR(20) | NOT NULL | Current state (see states below) |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Exchange creation timestamp |
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
| `completed_at` | TIMESTAMP | NULLABLE | When exchange was marked complete |
**Indexes**:
- `idx_exchange_slug` on `slug` (unique)
- `idx_exchange_state` on `state`
- `idx_exchange_exchange_date` on `exchange_date`
- `idx_exchange_completed_at` on `completed_at`
**States** (enum enforced at application level):
- `draft`: Exchange created but not accepting registrations
- `registration_open`: Participants can register
- `registration_closed`: Registration ended, ready for matching
- `matched`: Participants have been assigned recipients
- `completed`: Exchange date has passed
**State Transitions**:
- `draft``registration_open`
- `registration_open``registration_closed`
- `registration_closed``registration_open` (reopen)
- `registration_closed``matched` (after matching)
- `matched``registration_open` (reopen, clears matches)
- `matched``completed`
**Constraints**:
- `registration_close_date` must be before `exchange_date` (validated at application level)
- `max_participants` minimum value: 3
- Timezone must be valid IANA timezone (validated at application level)
- `slug` must be unique across all exchanges
**Slug Generation**:
- Generated on exchange creation using `secrets.choice()` from Python's secrets module
- 12 URL-safe alphanumeric characters (a-z, A-Z, 0-9)
- Immutable once generated (never changes)
- Used in public URLs for exchange registration and participant access
**Cascade Behavior**:
- Deleting exchange cascades to: participants, matches, exclusion rules, notification preferences
---
### Participant
A person registered in a specific exchange.
**Table**: `participant`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange |
| `name` | VARCHAR(255) | NOT NULL | Display name |
| `email` | VARCHAR(255) | NOT NULL | Email address |
| `gift_ideas` | TEXT | NULLABLE | Wishlist/gift preferences |
| `reminder_enabled` | BOOLEAN | NOT NULL, DEFAULT TRUE | Opt-in for reminder emails |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Registration timestamp |
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
| `withdrawn_at` | TIMESTAMP | NULLABLE | Withdrawal timestamp (soft delete) |
**Indexes**:
- `idx_participant_exchange_id` on `exchange_id`
- `idx_participant_email` on `email`
- `idx_participant_exchange_email` on `(exchange_id, email)` (composite unique)
**Constraints**:
- Email must be unique within an exchange (composite unique index)
- Email format validation at application level
- Cannot modify after matching except gift_ideas and reminder_enabled (enforced at application level)
**Cascade Behavior**:
- Deleting exchange cascades to delete participants
- Deleting participant cascades to: matches (as giver or receiver), exclusion rules, magic tokens
**Soft Delete**:
- Withdrawal sets `withdrawn_at` instead of hard delete
- Withdrawn participants excluded from matching and participant lists
- Withdrawn participants cannot be re-activated (must re-register)
---
### Match
Represents a giver-receiver assignment in an exchange.
**Table**: `match`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange |
| `giver_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Participant giving gift |
| `receiver_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Participant receiving gift |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Match creation timestamp |
**Indexes**:
- `idx_match_exchange_id` on `exchange_id`
- `idx_match_giver_id` on `giver_id`
- `idx_match_receiver_id` on `receiver_id`
- `idx_match_exchange_giver` on `(exchange_id, giver_id)` (composite unique)
**Constraints**:
- Each participant can be a giver exactly once per exchange (composite unique)
- Each participant can be a receiver exactly once per exchange (enforced at application level)
- `giver_id` cannot equal `receiver_id` (no self-matching, enforced at application level)
- Both giver and receiver must belong to same exchange (enforced at application level)
**Cascade Behavior**:
- Deleting exchange cascades to delete matches
- Deleting participant cascades to delete matches (triggers re-match requirement)
**Validation**:
- All participants in exchange must have exactly one match as giver and one as receiver
- No exclusion rules violated (enforced during matching)
---
### ExclusionRule
Defines pairs of participants who should not be matched together.
**Table**: `exclusion_rule`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange |
| `participant_a_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | First participant |
| `participant_b_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Second participant |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Rule creation timestamp |
**Indexes**:
- `idx_exclusion_exchange_id` on `exchange_id`
- `idx_exclusion_participants` on `(exchange_id, participant_a_id, participant_b_id)` (composite unique)
**Constraints**:
- `participant_a_id` and `participant_b_id` must be different (enforced at application level)
- Both participants must belong to same exchange (enforced at application level)
- Exclusion is bidirectional: A→B exclusion also means B→A (handled at application level)
- Prevent duplicate rules with swapped participant IDs (enforced by ordering IDs: `participant_a_id < participant_b_id`)
**Cascade Behavior**:
- Deleting exchange cascades to delete exclusion rules
- Deleting participant cascades to delete related exclusion rules
**Application Logic**:
- When adding exclusion, always store with lower ID as participant_a, higher ID as participant_b
- Matching algorithm treats exclusion as bidirectional
---
### MagicToken
Time-limited tokens for participant passwordless authentication and admin password reset.
**Table**: `magic_token`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `token_hash` | VARCHAR(255) | UNIQUE, NOT NULL | SHA-256 hash of token |
| `token_type` | VARCHAR(20) | NOT NULL | 'magic_link' or 'password_reset' |
| `email` | VARCHAR(255) | NOT NULL | Email address token sent to |
| `participant_id` | INTEGER | FOREIGN KEY → participant.id, NULLABLE | For magic links only |
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NULLABLE | For magic links only |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Token creation timestamp |
| `expires_at` | TIMESTAMP | NOT NULL | Token expiration (1 hour from creation) |
| `used_at` | TIMESTAMP | NULLABLE | When token was consumed |
**Indexes**:
- `idx_magic_token_hash` on `token_hash` (unique)
- `idx_magic_token_type_email` on `(token_type, email)`
- `idx_magic_token_expires_at` on `expires_at`
**Constraints**:
- `token_type` must be 'magic_link' or 'password_reset' (enforced at application level)
- For magic_link: `participant_id` and `exchange_id` must be NOT NULL
- For password_reset: `participant_id` and `exchange_id` must be NULL
- Tokens expire 1 hour after creation
- Tokens are single-use (validated via `used_at`)
**Token Generation**:
- Token: 32-byte random value from `secrets` module, base64url encoded
- Hash: SHA-256 hash of token (stored in database)
- Original token sent in email, never stored
**Validation**:
- Token valid if: hash matches, not expired, not used
- On successful validation: set `used_at` timestamp, create session
**Cleanup**:
- Expired or used tokens purged hourly via background job
**Cascade Behavior**:
- Deleting participant cascades to delete magic tokens
- Deleting exchange cascades to delete related magic tokens
---
### RateLimit
Tracks authentication attempt rate limits per email/key.
**Table**: `rate_limit`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `key` | VARCHAR(255) | UNIQUE, NOT NULL | Rate limit key (e.g., "login:email@example.com") |
| `attempts` | INTEGER | NOT NULL, DEFAULT 0 | Number of attempts in current window |
| `window_start` | TIMESTAMP | NOT NULL, DEFAULT NOW | Start of current rate limit window |
| `expires_at` | TIMESTAMP | NOT NULL | When rate limit resets |
**Indexes**:
- `idx_rate_limit_key` on `key` (unique)
- `idx_rate_limit_expires_at` on `expires_at`
**Rate Limit Policies**:
- Admin login: 5 attempts per 15 minutes per email
- Participant magic link: 3 requests per hour per email
- Password reset: 3 requests per hour per email
**Key Format**:
- Admin login: `login:admin:{email}`
- Magic link: `magic_link:{email}`
- Password reset: `password_reset:{email}`
**Workflow**:
1. Check if key exists and within window
2. If attempts exceeded: reject request
3. If within limits: increment attempts
4. If window expired: reset attempts and window
**Cleanup**:
- Expired rate limit entries purged daily via background job
---
### NotificationPreference
Admin's email notification preferences per exchange or globally.
**Table**: `notification_preference`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NULLABLE | Specific exchange (NULL = global default) |
| `new_registration` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on new participant registration |
| `participant_withdrawal` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on participant withdrawal |
| `matching_complete` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on successful matching |
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Preference creation timestamp |
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
**Indexes**:
- `idx_notification_exchange_id` on `exchange_id` (unique)
**Constraints**:
- Only one notification preference per exchange (unique index)
- Only one global preference (exchange_id = NULL)
**Application Logic**:
- If exchange-specific preference exists, use it
- Otherwise, fall back to global preference
- If no preferences exist, default all to TRUE
**Cascade Behavior**:
- Deleting exchange cascades to delete notification preferences
---
## Data Types & Conventions
### Timestamps
- **Type**: `TIMESTAMP` (SQLite stores as ISO 8601 string)
- **Timezone**: All timestamps stored in UTC
- **Application Layer**: Convert to exchange timezone for display
- **Automatic Fields**: `created_at`, `updated_at` managed by SQLAlchemy
### Email Addresses
- **Type**: `VARCHAR(255)`
- **Validation**: RFC 5322 format validation at application level
- **Storage**: Lowercase normalized
- **Indexing**: Indexed for lookup performance
### Enumerations
SQLite doesn't support native enums. Enforced at application level:
**ExchangeState**:
- `draft`
- `registration_open`
- `registration_closed`
- `matched`
- `completed`
**UserType** (Session):
- `admin`
- `participant`
**TokenType** (MagicToken):
- `magic_link`
- `password_reset`
### JSON Fields
- **Type**: `TEXT` (SQLite)
- **Serialization**: JSON string
- **Access**: SQLAlchemy JSON type provides automatic serialization/deserialization
- **Usage**: Session data, future extensibility
### Boolean Fields
- **Type**: `BOOLEAN` (SQLite stores as INTEGER: 0 or 1)
- **Default**: Explicit defaults defined per field
- **Access**: SQLAlchemy Boolean type handles conversion
---
## Performance Optimization
### Indexes Summary
| Table | Index Name | Columns | Type | Purpose |
|-------|------------|---------|------|---------|
| admin | idx_admin_email | email | UNIQUE | Login lookup |
| exchange | idx_exchange_slug | slug | UNIQUE | Exchange lookup by slug |
| exchange | idx_exchange_state | state | INDEX | Filter by state |
| exchange | idx_exchange_exchange_date | exchange_date | INDEX | Auto-completion job |
| exchange | idx_exchange_completed_at | completed_at | INDEX | Purge job |
| participant | idx_participant_exchange_id | exchange_id | INDEX | List participants |
| participant | idx_participant_email | email | INDEX | Lookup by email |
| participant | idx_participant_exchange_email | exchange_id, email | UNIQUE | Email uniqueness per exchange |
| match | idx_match_exchange_id | exchange_id | INDEX | List matches |
| match | idx_match_giver_id | giver_id | INDEX | Lookup giver's match |
| match | idx_match_receiver_id | receiver_id | INDEX | Lookup receivers |
| match | idx_match_exchange_giver | exchange_id, giver_id | UNIQUE | Prevent duplicate givers |
| exclusion_rule | idx_exclusion_exchange_id | exchange_id | INDEX | List exclusions |
| exclusion_rule | idx_exclusion_participants | exchange_id, participant_a_id, participant_b_id | UNIQUE | Prevent duplicates |
| magic_token | idx_magic_token_hash | token_hash | UNIQUE | Token validation |
| magic_token | idx_magic_token_type_email | token_type, email | INDEX | Rate limit checks |
| magic_token | idx_magic_token_expires_at | expires_at | INDEX | Cleanup job |
| rate_limit | idx_rate_limit_key | key | UNIQUE | Rate limit lookup |
| rate_limit | idx_rate_limit_expires_at | expires_at | INDEX | Cleanup job |
| notification_preference | idx_notification_exchange_id | exchange_id | UNIQUE | Preference lookup |
### Query Optimization Strategies
1. **Eager Loading**: Use SQLAlchemy `joinedload()` for relationships to prevent N+1 queries
2. **Batch Operations**: Use bulk insert/update for matching operations
3. **Filtered Queries**: Always filter by exchange_id first (most selective)
4. **Connection Pooling**: Disabled for SQLite (single file, no benefit)
5. **WAL Mode**: Enabled for better read concurrency
### SQLite Configuration
```python
# Connection string
DATABASE_URL = 'sqlite:///data/sneaky-klaus.db'
# Pragmas (set on connection)
PRAGMA journal_mode = WAL; # Write-Ahead Logging
PRAGMA foreign_keys = ON; # Enforce foreign keys
PRAGMA synchronous = NORMAL; # Balance safety and performance
PRAGMA temp_store = MEMORY; # Temporary tables in memory
PRAGMA cache_size = -64000; # 64MB cache
```
---
## Data Validation Rules
### Exchange
- `name`: 1-255 characters, required
- `slug`: Exactly 12 URL-safe alphanumeric characters, auto-generated, unique
- `budget`: 1-100 characters, required (freeform text)
- `max_participants`: ≥ 3, required
- `registration_close_date` < `exchange_date`: validated at application level
- `timezone`: Valid IANA timezone name
### Participant
- `name`: 1-255 characters, required
- `email`: Valid email format, unique per exchange
- `gift_ideas`: 0-10,000 characters (optional)
### Match
- Validation at matching time:
- All participants have exactly one match (as giver and receiver)
- No self-matches
- No exclusion rules violated
- Single cycle preferred
### ExclusionRule
- `participant_a_id` < `participant_b_id`: enforced when creating
- Both participants must be in same exchange
- Cannot exclude participant from themselves
### MagicToken
- Token expiration: 1 hour from creation
- Single use only
- Token hash must be unique
---
## Audit Trail
### Change Tracking
- All tables have `created_at` timestamp
- Most tables have `updated_at` timestamp (auto-updated)
- Soft deletes used where appropriate (`withdrawn_at` for participants)
### Future Audit Enhancements
If detailed audit trail is needed:
- Create `audit_log` table
- Track: entity type, entity ID, action, user, timestamp, changes
- Trigger-based or application-level logging
---
## Migration Strategy
### Initial Schema Creation
Use Alembic for database migrations:
```bash
# Create initial migration
alembic revision --autogenerate -m "Initial schema"
# Apply migration
alembic upgrade head
```
### Schema Versioning
- Alembic tracks migration version in `alembic_version` table
- Each schema change creates new migration file
- Migrations can be rolled back if needed
### Future Schema Changes
When adding fields or tables:
1. Create Alembic migration
2. Test migration on copy of production database
3. Run migration during deployment
4. Update SQLAlchemy models
---
## Sample Data Relationships
### Example: Complete Exchange Flow
1. **Exchange Created** (state: draft)
- 1 exchange record created
2. **Registration Opens** (state: registration_open)
- Exchange state updated
- Participants register (3-100 participant records)
3. **Registration Closes** (state: registration_closed)
- Exchange state updated
- Admin adds exclusion rules (0-N exclusion_rule records)
4. **Matching Occurs** (state: matched)
- Exchange state updated
- Match records created (N matches for N participants)
- Magic tokens created for notifications
5. **Exchange Completes** (state: completed)
- Exchange state updated
- `completed_at` timestamp set
- 30-day retention countdown begins
6. **Data Purge** (30 days after completion)
- Exchange deleted (cascades to participants, matches, exclusions)
---
## Database Size Estimates
### Assumptions
- 50 exchanges per installation
- Average 20 participants per exchange
- 3 exclusion rules per exchange
- 30-day retention = 5 active exchanges + 5 completed
### Storage Calculation
| Entity | Records | Avg Size | Total |
|--------|---------|----------|-------|
| Admin | 1 | 500 B | 500 B |
| Exchange | 50 | 1 KB | 50 KB |
| Participant | 1,000 | 2 KB | 2 MB |
| Match | 1,000 | 200 B | 200 KB |
| ExclusionRule | 150 | 200 B | 30 KB |
| Flask-Session (managed) | 50 | 500 B | 25 KB |
| MagicToken | 200 | 500 B | 100 KB |
| RateLimit | 100 | 300 B | 30 KB |
| NotificationPreference | 50 | 300 B | 15 KB |
**Total Estimated Size**: ~3 MB (excluding indexes and overhead)
**With Indexes & Overhead**: ~10 MB typical, ~50 MB maximum
SQLite database file size well within acceptable limits for self-hosted deployment.
---
## Backup & Recovery
### Backup Strategy
**Database File Location**: `/app/data/sneaky-klaus.db`
**Backup Methods**:
1. **Volume Snapshot**: Recommended for Docker deployments
```bash
docker run --rm -v sneaky-klaus-data:/data -v $(pwd):/backup \
alpine tar czf /backup/sneaky-klaus-backup-$(date +%Y%m%d).tar.gz /data
```
2. **SQLite Backup Command**: Online backup without downtime
```bash
sqlite3 sneaky-klaus.db ".backup sneaky-klaus-backup.db"
```
3. **File Copy**: Requires application shutdown
```bash
docker stop sneaky-klaus
cp /app/data/sneaky-klaus.db /backups/
docker start sneaky-klaus
```
**Backup Frequency**: Daily (automated via cron or external backup tool)
**Retention**: 30 days of backups
### Recovery Procedure
1. Stop application container
2. Replace database file with backup
3. Restart application
4. Verify health endpoint
5. Test basic functionality (login, view exchanges)
---
## Constraints Summary
### Foreign Key Relationships
| Child Table | Column | Parent Table | Parent Column | On Delete |
|-------------|--------|--------------|---------------|-----------|
| participant | exchange_id | exchange | id | CASCADE |
| match | exchange_id | exchange | id | CASCADE |
| match | giver_id | participant | id | CASCADE |
| match | receiver_id | participant | id | CASCADE |
| exclusion_rule | exchange_id | exchange | id | CASCADE |
| exclusion_rule | participant_a_id | participant | id | CASCADE |
| exclusion_rule | participant_b_id | participant | id | CASCADE |
| magic_token | participant_id | participant | id | CASCADE |
| magic_token | exchange_id | exchange | id | CASCADE |
| notification_preference | exchange_id | exchange | id | CASCADE |
**Flask-Session**: Managed by Flask-Session extension (no foreign keys in application schema)
**RateLimit**: No foreign keys (key-based, not entity-based)
### Unique Constraints
- `admin.email`: UNIQUE
- `exchange.slug`: UNIQUE
- `participant.(exchange_id, email)`: UNIQUE (composite)
- `match.(exchange_id, giver_id)`: UNIQUE (composite)
- `exclusion_rule.(exchange_id, participant_a_id, participant_b_id)`: UNIQUE (composite)
- `magic_token.token_hash`: UNIQUE
- `rate_limit.key`: UNIQUE
- `notification_preference.exchange_id`: UNIQUE (one preference per exchange)
### Check Constraints
- `exchange.max_participants >= 3`
- Application-level validation for additional constraints (state transitions, date ordering, etc.)
---
## Future Schema Enhancements
Potential additions for future versions:
1. **Audit Log Table**: Track all changes for compliance/debugging
2. **Email Queue Table**: Decouple email sending from transactional operations
3. **Participant Groups**: Support for couple/family groupings (exclude from each other automatically)
4. **Multiple Admins**: Add admin roles and permissions
5. **Exchange Templates**: Reusable exchange configurations
6. **Custom Fields**: User-defined participant attributes
These enhancements would require schema changes but are out of scope for v0.1.0.
---
## References
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
- [SQLite Documentation](https://www.sqlite.org/docs.html)
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)

View File

@@ -0,0 +1,592 @@
# System Architecture Overview - v0.1.0
**Version**: 0.1.0
**Date**: 2025-12-22
**Status**: Initial Design
## Introduction
This document describes the high-level architecture for Sneaky Klaus, a self-hosted Secret Santa organization application. The architecture prioritizes simplicity, ease of deployment, and minimal external dependencies while maintaining security and reliability.
## System Architecture
### Deployment Model
Sneaky Klaus is deployed as a **single Docker container** containing all application components:
```mermaid
graph TB
subgraph "Docker Container"
direction TB
Flask[Flask Application]
Gunicorn[Gunicorn WSGI Server]
APScheduler[APScheduler Background Jobs]
SQLite[(SQLite Database)]
Gunicorn --> Flask
Flask --> SQLite
APScheduler --> Flask
APScheduler --> SQLite
end
subgraph "External Services"
Resend[Resend Email API]
end
subgraph "Clients"
AdminBrowser[Admin Browser]
ParticipantBrowser[Participant Browser]
end
AdminBrowser -->|HTTPS| Gunicorn
ParticipantBrowser -->|HTTPS| Gunicorn
Flask -->|HTTPS| Resend
subgraph "Persistent Storage"
DBVolume[Database Volume]
SQLite -.->|Mounted| DBVolume
end
```
### Component Responsibilities
| Component | Responsibility | Technology |
|-----------|----------------|------------|
| **Gunicorn** | HTTP request handling, worker process management | Gunicorn 21.x |
| **Flask Application** | Request routing, business logic, template rendering | Flask 3.x |
| **SQLite Database** | Data persistence, transactional storage | SQLite 3.40+ |
| **APScheduler** | Background job scheduling (reminders, data purging) | APScheduler 3.10+ |
| **Jinja2** | Server-side HTML template rendering | Jinja2 3.1+ |
| **Resend** | Transactional email delivery | Resend API |
### Application Architecture
The Flask application follows a layered architecture:
```mermaid
graph TB
subgraph "Presentation Layer"
Routes[Route Handlers]
Templates[Jinja2 Templates]
Forms[WTForms Validation]
end
subgraph "Business Logic Layer"
Services[Service Layer]
Auth[Authentication Service]
Matching[Matching Algorithm]
Notifications[Notification Service]
end
subgraph "Data Access Layer"
Models[SQLAlchemy Models]
Repositories[Repository Pattern]
end
subgraph "Infrastructure"
Database[(SQLite)]
EmailProvider[Resend Email]
Scheduler[APScheduler]
end
Routes --> Services
Routes --> Forms
Routes --> Templates
Services --> Models
Services --> Auth
Services --> Matching
Services --> Notifications
Models --> Repositories
Repositories --> Database
Notifications --> EmailProvider
Scheduler --> Services
```
## Core Components
### Flask Application Structure
```
src/
├── app.py # Application factory
├── config.py # Configuration management
├── models/ # SQLAlchemy models
│ ├── admin.py
│ ├── exchange.py
│ ├── participant.py
│ ├── match.py
│ ├── session.py
│ └── auth_token.py
├── routes/ # Route handlers (blueprints)
│ ├── admin.py
│ ├── participant.py
│ ├── exchange.py
│ └── auth.py
├── services/ # Business logic
│ ├── auth_service.py
│ ├── exchange_service.py
│ ├── matching_service.py
│ ├── notification_service.py
│ └── scheduler_service.py
├── templates/ # Jinja2 templates
│ ├── admin/
│ ├── participant/
│ ├── auth/
│ └── layouts/
├── static/ # Static assets (CSS, minimal JS)
│ ├── css/
│ └── js/
└── utils/ # Utility functions
├── email.py
├── security.py
└── validators.py
```
### Database Layer
**ORM**: SQLAlchemy for database abstraction and model definition
**Migration**: Alembic for schema versioning and migrations
**Configuration**:
- WAL mode enabled for better concurrency
- Foreign keys enabled
- Connection pooling disabled (single file, single process benefit)
- Appropriate timeouts for locked database scenarios
### Background Job Scheduler
**APScheduler Configuration**:
- JobStore: SQLAlchemyJobStore (persists jobs across restarts)
- Executor: ThreadPoolExecutor (4 workers)
- Timezone-aware scheduling
**Scheduled Jobs**:
1. **Reminder Emails**: Cron jobs scheduled per exchange based on configured intervals
2. **Exchange Completion**: Daily check for exchanges past their exchange date
3. **Data Purging**: Daily check for completed exchanges past 30-day retention
4. **Session Cleanup**: Daily purge of expired sessions
5. **Token Cleanup**: Hourly purge of expired auth tokens
### Email Service
**Provider**: Resend API via official Python SDK
**Email Types**:
- Participant registration confirmation
- Magic link authentication
- Match notification (post-matching)
- Reminder emails (configurable schedule)
- Admin notifications (opt-in)
- Password reset
**Template Strategy**:
- HTML templates stored in `templates/emails/`
- Rendered using Jinja2 before sending
- Plain text alternatives for all emails
- Unsubscribe links where appropriate
## Configuration Management
### Environment Variables
| Variable | Purpose | Required | Default |
|----------|---------|----------|---------|
| `SECRET_KEY` | Flask session encryption | Yes | - |
| `DATABASE_URL` | SQLite database file path | No | `sqlite:///data/sneaky-klaus.db` |
| `RESEND_API_KEY` | Resend API authentication | Yes | - |
| `APP_URL` | Base URL for links in emails | Yes | - |
| `ADMIN_EMAIL` | Initial admin email (setup) | Setup only | - |
| `ADMIN_PASSWORD` | Initial admin password (setup) | Setup only | - |
| `LOG_LEVEL` | Logging verbosity | No | `INFO` |
| `TZ` | Container timezone | No | `UTC` |
### Configuration Files
**config.py**: Python-based configuration with environment variable overrides
**Environment-based configs**:
- `DevelopmentConfig`: Debug mode, verbose logging
- `ProductionConfig`: Security headers, minimal logging
- `TestConfig`: In-memory database, mocked email
## Deployment Architecture
### Docker Container
**Base Image**: `python:3.11-slim`
**Exposed Ports**:
- `8000`: HTTP (Gunicorn)
**Volumes**:
- `/app/data`: Database and uploaded files (if any)
**Health Check**:
- Endpoint: `/health`
- Interval: 30 seconds
- Timeout: 5 seconds
**Dockerfile Structure**:
```dockerfile
FROM python:3.11-slim
# Install uv
RUN pip install uv
# Copy application
WORKDIR /app
COPY . /app
# Install dependencies
RUN uv sync --frozen
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uv", "run", "gunicorn", "-c", "gunicorn.conf.py", "src.app:create_app()"]
```
### Reverse Proxy Configuration
**Recommended**: Deploy behind reverse proxy (Nginx, Traefik, Caddy) for:
- HTTPS termination
- Rate limiting (additional layer beyond app-level)
- Static file caching
- Request buffering
**Example Nginx Config**:
```nginx
server {
listen 443 ssl http2;
server_name secretsanta.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://sneaky-klaus:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
proxy_pass http://sneaky-klaus:8000/static;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
## Security Architecture
### Authentication & Authorization
See [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md) for detailed authentication design.
**Summary**:
- Admin: Password-based authentication with bcrypt hashing
- Participant: Magic link authentication with time-limited tokens
- Server-side sessions with secure cookies
- Rate limiting on all authentication endpoints
### Security Headers
Flask-Talisman configured with:
- `Content-Security-Policy`: Restrict script sources
- `X-Frame-Options`: Prevent clickjacking
- `X-Content-Type-Options`: Prevent MIME sniffing
- `Strict-Transport-Security`: Enforce HTTPS
- `Referrer-Policy`: Control referrer information
### CSRF Protection
Flask-WTF provides CSRF tokens for all forms:
- Tokens embedded in forms automatically
- Tokens validated on POST/PUT/DELETE requests
- SameSite cookie attribute provides additional protection
### Input Validation
- WTForms for form validation
- SQLAlchemy parameterized queries prevent SQL injection
- Jinja2 auto-escaping prevents XSS
- Email validation for all email inputs
### Secrets Management
- All secrets stored in environment variables
- Never committed to version control
- Docker secrets or .env file for local development
- Secret rotation supported through environment updates
## Data Flow Examples
### Participant Registration Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
participant E as Resend Email
P->>F: GET /exchange/{id}/register
F->>DB: Query exchange details
DB-->>F: Exchange data
F-->>P: Registration form
P->>F: POST /exchange/{id}/register (name, email, gift ideas)
F->>F: Validate form
F->>DB: Check email uniqueness in exchange
F->>DB: Insert participant record
DB-->>F: Participant created
F->>F: Generate magic link token
F->>DB: Store auth token
F->>E: Send confirmation email with magic link
E-->>F: Email accepted
F-->>P: Registration success page
```
### Matching Flow
```mermaid
sequenceDiagram
participant A as Admin Browser
participant F as Flask App
participant M as Matching Service
participant DB as SQLite Database
participant E as Resend Email
A->>F: POST /admin/exchange/{id}/match
F->>DB: Get all participants
F->>DB: Get exclusion rules
F->>M: Execute matching algorithm
M->>M: Generate valid assignments
M-->>F: Match assignments
F->>DB: Store matches (transaction)
DB-->>F: Matches saved
F->>E: Send match notification to each participant
E-->>F: Emails queued
F->>DB: Update exchange state to "Matched"
F-->>A: Matching complete
```
### Reminder Email Flow
```mermaid
sequenceDiagram
participant S as APScheduler
participant F as Flask App
participant DB as SQLite Database
participant E as Resend Email
S->>F: Trigger reminder job
F->>DB: Query exchanges needing reminders today
DB-->>F: Exchange list
loop For each exchange
F->>DB: Get opted-in participants
DB-->>F: Participant list
loop For each participant
F->>DB: Get participant's match
F->>E: Send reminder email
E-->>F: Email sent
end
end
F->>DB: Log reminder job completion
```
## Performance Considerations
### Expected Load
- **Concurrent Users**: 10-50 typical, 100 maximum
- **Exchanges**: 10-100 per installation
- **Participants per Exchange**: 3-100 typical, 500 maximum
- **Database Size**: <100MB typical, <1GB maximum
### Scaling Strategy
**Vertical Scaling**: Increase container resources (CPU, memory) as needed
**Horizontal Scaling**: Not supported due to SQLite limitation. If horizontal scaling becomes necessary:
1. Migrate database to PostgreSQL
2. Externalize session storage (Redis)
3. Deploy multiple application instances behind load balancer
For the target use case (self-hosted Secret Santa), vertical scaling is sufficient.
### Caching Strategy
**Initial Version**: No caching layer (premature optimization)
**Future Optimization** (if needed):
- Flask-Caching for expensive queries (participant lists, exchange details)
- Redis for session storage (if horizontal scaling needed)
- Reverse proxy caching for static assets
### Database Optimization
- Indexes on frequently queried fields (email, exchange_id, token_hash)
- WAL mode for improved read concurrency
- VACUUM scheduled periodically (after data purges)
- Query optimization through SQLAlchemy query analysis
## Monitoring & Observability
### Logging
**Python logging module** with structured logging:
- **Log Levels**: DEBUG, INFO, WARNING, ERROR, CRITICAL
- **Log Format**: JSON for production, human-readable for development
- **Log Outputs**: stdout (captured by Docker)
**Logged Events**:
- Authentication attempts (success and failure)
- Exchange state transitions
- Matching operations (start, success, failure)
- Email send operations
- Background job execution
- Error exceptions with stack traces
### Metrics (Future)
Potential metrics to track:
- Request count and latency by endpoint
- Authentication success/failure rates
- Email delivery success rates
- Background job execution duration
- Database query performance
**Implementation**: Prometheus metrics endpoint (optional enhancement)
### Health Checks
**`/health` endpoint** returns:
- HTTP 200: Application healthy
- HTTP 503: Application unhealthy (database unreachable, critical failure)
**Checks**:
- Database connectivity
- Email service reachability (optional, cached)
- Scheduler running status
## Disaster Recovery
### Backup Strategy
**Database Backup**:
- SQLite file located at `/app/data/sneaky-klaus.db`
- Backup via volume snapshots or file copy
- Recommended frequency: Daily automatic backups
- Retention: 30 days
**Backup Methods**:
1. Volume snapshots (Docker volume backup)
2. `sqlite3 .backup` command (online backup)
3. File copy (requires application shutdown for consistency)
### Restore Procedure
1. Stop container
2. Replace database file with backup
3. Start container
4. Verify application health
### Data Export
**Future Enhancement**: Admin export functionality
- CSV export of participants and exchanges
- JSON export for full data portability
## Development Workflow
### Local Development
```bash
# Clone repository
git clone https://github.com/user/sneaky-klaus.git
cd sneaky-klaus
# Install dependencies
uv sync
# Set up environment variables
cp .env.example .env
# Edit .env with local values
# Run database migrations
uv run alembic upgrade head
# Run development server
uv run flask run
```
### Testing Strategy
**Test Levels**:
1. **Unit Tests**: Business logic, utilities (pytest)
2. **Integration Tests**: Database operations, email sending (pytest + fixtures)
3. **End-to-End Tests**: Full user flows (Playwright or Selenium)
**Test Coverage Target**: 80%+ for business logic
### CI/CD Pipeline
**Continuous Integration**:
- Run tests on every commit
- Lint code (ruff, mypy)
- Build Docker image
- Security scanning (bandit, safety)
**Continuous Deployment**:
- Tag releases (semantic versioning)
- Push Docker image to registry
- Update deployment documentation
## Future Architectural Considerations
### Potential Enhancements
1. **Multi-tenancy**: Support multiple isolated admin accounts (requires significant schema changes)
2. **PostgreSQL Support**: Optional PostgreSQL backend for larger deployments
3. **Horizontal Scaling**: Redis session storage, multi-instance deployment
4. **API**: REST API for programmatic access or mobile apps
5. **Webhooks**: Notify external systems of events
6. **Internationalization**: Multi-language support
### Migration Paths
If the application needs to scale beyond SQLite:
1. **Database Migration**: Alembic migrations can be adapted for PostgreSQL
2. **Session Storage**: Move to Redis for distributed sessions
3. **Job Queue**: Move to Celery + Redis for distributed background jobs
4. **File Storage**: Move to S3-compatible storage if file uploads are added
These migrations would be disruptive and are not planned for initial versions.
## Conclusion
This architecture prioritizes simplicity and ease of self-hosting while maintaining security, reliability, and maintainability. The single-container deployment model minimizes operational complexity, making Sneaky Klaus accessible to non-technical users who want to self-host their Secret Santa exchanges.
The design is deliberately conservative, avoiding premature optimization and complex infrastructure. Future enhancements can be added incrementally without requiring fundamental architectural changes.
## References
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
- [Flask Documentation](https://flask.palletsprojects.com/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
- [APScheduler Documentation](https://apscheduler.readthedocs.io/)