feat: add Participant and MagicToken models with automatic migrations
Implements Phase 2 infrastructure for participant registration and authentication: Database Models: - Add Participant model with exchange scoping and soft deletes - Add MagicToken model for passwordless authentication - Add participants relationship to Exchange model - Include proper indexes and foreign key constraints Migration Infrastructure: - Generate Alembic migration for new models - Create entrypoint.sh script for automatic migrations on container startup - Update Containerfile to use entrypoint script and include uv binary - Remove db.create_all() in favor of migration-based schema management This establishes the foundation for implementing stories 4.1-4.3, 5.1-5.3, and 10.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,18 @@ RUN useradd --create-home --shell /bin/bash appuser
|
|||||||
# Copy virtual environment from builder
|
# Copy virtual environment from builder
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
# Copy uv for running alembic in entrypoint
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
COPY alembic.ini main.py ./
|
COPY alembic.ini main.py ./
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh ./
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
@@ -47,5 +54,5 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
# Run with gunicorn
|
# Run with entrypoint script
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "main:app"]
|
CMD ["./entrypoint.sh"]
|
||||||
|
|||||||
369
docs/decisions/0003-participant-session-scoping.md
Normal file
369
docs/decisions/0003-participant-session-scoping.md
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
# 0003. Participant Session Scoping
|
||||||
|
|
||||||
|
Date: 2025-12-22
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Participants in Sneaky Klaus can register for multiple Secret Santa exchanges using the same email address. Each exchange is independent, and we need to decide how to handle authentication and sessions when a participant belongs to multiple exchanges.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. **Privacy**: Participants in one exchange should not see data from other exchanges
|
||||||
|
2. **Simplicity**: Authentication should remain frictionless (magic links)
|
||||||
|
3. **Independence**: Each exchange operates independently
|
||||||
|
4. **Security**: Sessions must be properly isolated
|
||||||
|
|
||||||
|
### Options Considered
|
||||||
|
|
||||||
|
We evaluated three approaches for handling participants across multiple exchanges:
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will implement **exchange-scoped sessions** where:
|
||||||
|
|
||||||
|
1. Each participant registration creates a separate `Participant` record per exchange
|
||||||
|
2. Each magic link creates a session scoped to a single exchange
|
||||||
|
3. Participant data and matches are isolated per exchange
|
||||||
|
4. To access a different exchange, participant must use that exchange's magic link
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
**Separate Participant Records**:
|
||||||
|
- Alice registering for "Family Christmas" and "Office Party" creates two distinct `Participant` records
|
||||||
|
- Each record has its own ID, name, gift ideas, and preferences
|
||||||
|
- Email is the same, but records are independent
|
||||||
|
- Simple data model with clear foreign key relationships
|
||||||
|
|
||||||
|
**Exchange-Scoped Sessions**:
|
||||||
|
- Magic link authentication creates session with: `{'user_id': participant_id, 'exchange_id': exchange_id}`
|
||||||
|
- Session grants access only to the associated exchange
|
||||||
|
- Participant cannot view or modify data from other exchanges in same session
|
||||||
|
- Clean security boundary
|
||||||
|
|
||||||
|
**Multiple Exchanges Require Multiple Logins**:
|
||||||
|
- Participant must authenticate separately for each exchange
|
||||||
|
- Each exchange's magic link creates a new session (replacing previous session)
|
||||||
|
- No "switch exchange" functionality - use appropriate magic link
|
||||||
|
- Simple to implement and reason about
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Security**: Clear isolation between exchanges; no risk of data leakage
|
||||||
|
- **Simplicity**: Straightforward implementation with no complex multi-exchange logic
|
||||||
|
- **Data Model**: Clean foreign key relationships; each participant belongs to exactly one exchange
|
||||||
|
- **Privacy**: Participants in Exchange A cannot discover participants in Exchange B
|
||||||
|
- **Scalability**: No need for complex access control lists or permission systems
|
||||||
|
- **Testing**: Easy to test; each exchange operates independently
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **User Experience**: Participant in multiple exchanges must keep multiple magic links
|
||||||
|
- **Email Volume**: Separate confirmation emails for each exchange registration
|
||||||
|
- **No Unified View**: Participant cannot see all their exchanges in one dashboard
|
||||||
|
- **Duplicate Data**: Same participant name/preferences stored multiple times
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- **Email Address**: Same email can appear in multiple exchanges (expected behavior)
|
||||||
|
- **Session Management**: Only one active participant session at a time (last magic link wins)
|
||||||
|
- **Magic Link Storage**: Participant should save/bookmark magic links for each exchange
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Two separate Participant records for Alice in two exchanges
|
||||||
|
Participant(
|
||||||
|
id=100,
|
||||||
|
exchange_id=1, # Family Christmas
|
||||||
|
email="alice@example.com",
|
||||||
|
name="Alice Smith",
|
||||||
|
gift_ideas="Books"
|
||||||
|
)
|
||||||
|
|
||||||
|
Participant(
|
||||||
|
id=200,
|
||||||
|
exchange_id=2, # Office Party
|
||||||
|
email="alice@example.com",
|
||||||
|
name="Alice Smith",
|
||||||
|
gift_ideas="Coffee mug"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Family Christmas session
|
||||||
|
session = {
|
||||||
|
'user_id': 100,
|
||||||
|
'user_type': 'participant',
|
||||||
|
'exchange_id': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Office Party session (replaces Family Christmas session)
|
||||||
|
session = {
|
||||||
|
'user_id': 200,
|
||||||
|
'user_type': 'participant',
|
||||||
|
'exchange_id': 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Protection
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.route('/participant/exchange/<int:exchange_id>')
|
||||||
|
@participant_required
|
||||||
|
@exchange_access_required
|
||||||
|
def view_exchange(exchange_id):
|
||||||
|
"""
|
||||||
|
Participant can only view exchange if:
|
||||||
|
1. They are authenticated (participant_required)
|
||||||
|
2. Their session's exchange_id matches the route's exchange_id
|
||||||
|
"""
|
||||||
|
# g.participant.exchange_id must equal exchange_id
|
||||||
|
# Otherwise: 403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Experience Implications
|
||||||
|
|
||||||
|
### Registration Email
|
||||||
|
|
||||||
|
When Alice registers for both exchanges, she receives two emails:
|
||||||
|
|
||||||
|
**Email 1** (Family Christmas):
|
||||||
|
```
|
||||||
|
Subject: Welcome to Family Christmas!
|
||||||
|
|
||||||
|
Hi Alice,
|
||||||
|
|
||||||
|
You've successfully registered for the Secret Santa exchange!
|
||||||
|
|
||||||
|
[Access My Registration] (magic link for Family Christmas)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Email 2** (Office Party):
|
||||||
|
```
|
||||||
|
Subject: Welcome to Office Party!
|
||||||
|
|
||||||
|
Hi Alice,
|
||||||
|
|
||||||
|
You've successfully registered for the Secret Santa exchange!
|
||||||
|
|
||||||
|
[Access My Registration] (magic link for Office Party)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Multiple Exchanges
|
||||||
|
|
||||||
|
**Scenario**: Alice clicks "Family Christmas" magic link, views her assignment, then clicks "Office Party" magic link.
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
1. "Family Christmas" link creates session with exchange_id=1
|
||||||
|
2. Alice views Family Christmas dashboard
|
||||||
|
3. "Office Party" link creates NEW session with exchange_id=2 (replaces previous)
|
||||||
|
4. Alice now views Office Party dashboard
|
||||||
|
5. To return to Family Christmas, must click Family Christmas magic link again
|
||||||
|
|
||||||
|
**Recommendation**: Advise participants to:
|
||||||
|
- Bookmark magic links for each exchange
|
||||||
|
- Keep confirmation emails for future access
|
||||||
|
- Request new magic links anytime via registration page
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Alternative 1: Unified Multi-Exchange Sessions
|
||||||
|
|
||||||
|
**Approach**: Create a single participant identity across all exchanges.
|
||||||
|
|
||||||
|
**How it would work**:
|
||||||
|
- Single `Participant` record per email (not per exchange)
|
||||||
|
- Many-to-many relationship: Participant ←→ Exchange
|
||||||
|
- Session grants access to all exchanges for that email
|
||||||
|
- Dashboard shows all exchanges participant is in
|
||||||
|
|
||||||
|
**Why rejected**:
|
||||||
|
- **Complexity**: Requires many-to-many schema, complex access control
|
||||||
|
- **Privacy concerns**: Easier to accidentally leak cross-exchange data
|
||||||
|
- **Name conflicts**: Participant might use different names in different exchanges
|
||||||
|
- **Preferences diverge**: Gift ideas, reminder settings differ per exchange
|
||||||
|
- **Admin complexity**: Harder to reason about "removing participant from exchange"
|
||||||
|
|
||||||
|
### Alternative 2: Multi-Exchange Sessions with Switching
|
||||||
|
|
||||||
|
**Approach**: Session grants access to all exchanges, with UI to switch active exchange.
|
||||||
|
|
||||||
|
**How it would work**:
|
||||||
|
- Separate Participant records (like chosen approach)
|
||||||
|
- Session contains list of participant_ids: `{'participant_ids': [100, 200]}`
|
||||||
|
- UI dropdown to "switch active exchange"
|
||||||
|
- Active exchange stored in session: `{'active_exchange_id': 1}`
|
||||||
|
|
||||||
|
**Why rejected**:
|
||||||
|
- **Complexity**: Session management more complex
|
||||||
|
- **Security risk**: Easier to introduce bugs that show wrong exchange data
|
||||||
|
- **Marginal UX benefit**: Switching requires UI action anyway; magic link is simpler
|
||||||
|
- **Testing burden**: Must test exchange switching logic
|
||||||
|
- **Session size**: Session grows with number of exchanges
|
||||||
|
|
||||||
|
### Alternative 3: No Multiple Exchange Support
|
||||||
|
|
||||||
|
**Approach**: Enforce email uniqueness globally across all exchanges.
|
||||||
|
|
||||||
|
**How it would work**:
|
||||||
|
- Email can only be used in one exchange per installation
|
||||||
|
- Attempting to register with same email in second exchange fails
|
||||||
|
|
||||||
|
**Why rejected**:
|
||||||
|
- **User frustration**: Reasonable to participate in multiple exchanges
|
||||||
|
- **Workaround temptation**: Users would use alice+family@example.com, alice+work@example.com
|
||||||
|
- **Use case mismatch**: Common scenario is family member organizing multiple exchanges
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Attack Vector: Exchange Data Leakage
|
||||||
|
|
||||||
|
**Threat**: Participant in Exchange A attempts to view data from Exchange B.
|
||||||
|
|
||||||
|
**Mitigation**:
|
||||||
|
- All participant routes check `session['exchange_id']` matches route parameter
|
||||||
|
- Database queries filter by both `participant_id` AND `exchange_id`
|
||||||
|
- No API or UI allows listing exchanges for an email
|
||||||
|
|
||||||
|
**Example Protection**:
|
||||||
|
```python
|
||||||
|
@exchange_access_required
|
||||||
|
def view_exchange(exchange_id):
|
||||||
|
# Decorator checks: g.participant.exchange_id == exchange_id
|
||||||
|
# If mismatch: 403 Forbidden, redirect to correct exchange
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attack Vector: Session Hijacking
|
||||||
|
|
||||||
|
**Threat**: Attacker steals participant session cookie, accesses exchange.
|
||||||
|
|
||||||
|
**Mitigation**:
|
||||||
|
- Standard session security (HttpOnly, Secure, SameSite=Lax)
|
||||||
|
- Session scoped to single exchange limits damage
|
||||||
|
- 7-day session expiration
|
||||||
|
- No sensitive financial or personal data stored
|
||||||
|
|
||||||
|
### Attack Vector: Email Enumeration Across Exchanges
|
||||||
|
|
||||||
|
**Threat**: Attacker checks if email is registered in multiple exchanges.
|
||||||
|
|
||||||
|
**Mitigation**:
|
||||||
|
- Magic link request returns generic success message
|
||||||
|
- No API reveals which exchanges an email is registered in
|
||||||
|
- Rate limiting prevents automated enumeration
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_participant_isolated_by_exchange():
|
||||||
|
"""Test that participants are isolated per exchange."""
|
||||||
|
# Create two exchanges
|
||||||
|
exchange1 = create_exchange(name="Exchange 1")
|
||||||
|
exchange2 = create_exchange(name="Exchange 2")
|
||||||
|
|
||||||
|
# Register Alice in both
|
||||||
|
alice1 = register_participant(exchange1.slug, {
|
||||||
|
'email': 'alice@example.com',
|
||||||
|
'name': 'Alice',
|
||||||
|
'gift_ideas': 'Books'
|
||||||
|
})
|
||||||
|
|
||||||
|
alice2 = register_participant(exchange2.slug, {
|
||||||
|
'email': 'alice@example.com',
|
||||||
|
'name': 'Alice',
|
||||||
|
'gift_ideas': 'Coffee'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Different participant IDs
|
||||||
|
assert alice1.id != alice2.id
|
||||||
|
|
||||||
|
# Different exchange IDs
|
||||||
|
assert alice1.exchange_id == exchange1.id
|
||||||
|
assert alice2.exchange_id == exchange2.id
|
||||||
|
|
||||||
|
def test_session_scoping():
|
||||||
|
"""Test that session grants access only to associated exchange."""
|
||||||
|
# Create session for exchange 1
|
||||||
|
create_participant_session(participant_id=100, exchange_id=1)
|
||||||
|
|
||||||
|
# Can access exchange 1
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.get('/participant/exchange/1')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Cannot access exchange 2
|
||||||
|
response = client.get('/participant/exchange/2')
|
||||||
|
assert response.status_code == 403
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_multiple_exchange_magic_links():
|
||||||
|
"""Test magic links for different exchanges create appropriate sessions."""
|
||||||
|
# Register for exchange 1
|
||||||
|
token1 = register_and_get_magic_link('exchange1-slug', 'alice@example.com')
|
||||||
|
|
||||||
|
# Register for exchange 2
|
||||||
|
token2 = register_and_get_magic_link('exchange2-slug', 'alice@example.com')
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
# Use token 1
|
||||||
|
client.get(f'/auth/participant/magic/{token1}')
|
||||||
|
|
||||||
|
# Should have access to exchange 1
|
||||||
|
response = client.get('/participant/exchange/1')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Use token 2 (creates new session)
|
||||||
|
client.get(f'/auth/participant/magic/{token2}')
|
||||||
|
|
||||||
|
# Should now have access to exchange 2
|
||||||
|
response = client.get('/participant/exchange/2')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# No longer have access to exchange 1 (session replaced)
|
||||||
|
response = client.get('/participant/exchange/1')
|
||||||
|
assert response.status_code == 403
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
### Potential Enhancement: Multi-Exchange Dashboard
|
||||||
|
|
||||||
|
If user feedback indicates strong need for unified view, could implement:
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
- Add route: `/participant/all` (no auth required, email verification only)
|
||||||
|
- Participant enters email, receives magic link
|
||||||
|
- Magic link validates email, shows read-only list of all exchanges for that email
|
||||||
|
- Each exchange has "Access" button → sends exchange-specific magic link
|
||||||
|
- Dashboard itself is stateless (no session), just email verification
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Participants can see all their exchanges in one place
|
||||||
|
- Still maintains exchange-scoped sessions for actual data access
|
||||||
|
- Optional feature; magic links still work independently
|
||||||
|
|
||||||
|
**Implementation Complexity**: Medium (new routes, new email template, new UI)
|
||||||
|
|
||||||
|
**Recommendation**: Defer until Phase 8 or later based on user feedback
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-0002: Authentication Strategy](./0002-authentication-strategy.md)
|
||||||
|
- [Participant Auth Component Design](../designs/v0.2.0/components/participant-auth.md)
|
||||||
|
- [Data Model v0.2.0](../designs/v0.2.0/data-model.md)
|
||||||
|
- [Flask Session Documentation](https://flask.palletsprojects.com/en/latest/quickstart/#sessions)
|
||||||
353
docs/decisions/0004-dev-mode-email-logging.md
Normal file
353
docs/decisions/0004-dev-mode-email-logging.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# 0004. Development Mode Email Logging
|
||||||
|
|
||||||
|
Date: 2025-12-22
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
When testing Sneaky Klaus in development and QA environments without email access, participants cannot receive magic links via email. This blocks QA teams from testing the authentication flow and participant workflows.
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
|
||||||
|
1. **No Email Service in Dev/QA**: Development environments often don't have access to Resend or external email services
|
||||||
|
2. **Testing Blocker**: Without magic links, QA cannot test participant registration, authentication, and session flows
|
||||||
|
3. **Local Development**: Developers need a way to obtain magic links when working locally without email infrastructure
|
||||||
|
4. **Security Requirement**: This feature MUST be completely disabled in production
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. **Development Only**: Feature must only activate when `FLASK_ENV=development`
|
||||||
|
2. **Logging**: Full magic link URLs logged to application logs for retrieval
|
||||||
|
3. **Accessibility**: QA can retrieve links from container logs using standard tools
|
||||||
|
4. **No Production Exposure**: Absolutely cannot be enabled in production
|
||||||
|
5. **Clear Documentation**: Must warn developers about security implications
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will implement **development mode email logging** where:
|
||||||
|
|
||||||
|
1. When `FLASK_ENV=development`, magic link generation logs the complete URL to application logs
|
||||||
|
2. The full magic link URL (including token) is logged at INFO level with a clear DEV MODE prefix
|
||||||
|
3. QA retrieves links from container logs using `podman logs` or equivalent tools
|
||||||
|
4. A runtime check prevents this feature from ever being enabled in production, even if the code exists
|
||||||
|
5. Email is still sent (when available), but we also provide the logging fallback
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
**Why Log Instead of Display in UI**:
|
||||||
|
- Magic links contain 32 bytes of cryptographic randomness - not easily displayed in a UX-friendly way
|
||||||
|
- Logging to application logs is a standard development practice
|
||||||
|
- Container logs are easily accessible in QA environments
|
||||||
|
- No code changes needed for QA to access links
|
||||||
|
|
||||||
|
**Why Log to Application Logs**:
|
||||||
|
- Standard location for operational information
|
||||||
|
- Automatically captured by container logging systems
|
||||||
|
- Can be retrieved with `podman logs` without code changes
|
||||||
|
- Works across all deployment scenarios (local, Docker, Podman, etc.)
|
||||||
|
|
||||||
|
**Why Require FLASK_ENV=development**:
|
||||||
|
- Environment-based gates are best practice for dev-only features
|
||||||
|
- Clear, explicit control
|
||||||
|
- Cannot be accidentally enabled in production
|
||||||
|
- Aligns with Flask conventions
|
||||||
|
|
||||||
|
**Why Still Send Email**:
|
||||||
|
- When email IS available, participants get the real experience
|
||||||
|
- Logging is a fallback for testing without email
|
||||||
|
- Both paths work; QA can test with or without actual email service
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Unblocks QA**: Testing can proceed without email infrastructure
|
||||||
|
- **Developer Friendly**: Developers can work locally without email setup
|
||||||
|
- **Simple Implementation**: Minimal code changes required
|
||||||
|
- **Standard Practice**: Logging is a standard development pattern
|
||||||
|
- **Production Safe**: Environment-based control prevents any production exposure
|
||||||
|
- **No Breaking Changes**: Existing email workflow unchanged
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **Requires Awareness**: QA/developers must know to check logs for links
|
||||||
|
- **Log Noise**: Application logs include URLs (only in development)
|
||||||
|
- **Copy-Paste**: Manual link copying from logs is less convenient than email
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- **No UI Changes**: No changes to participant-facing experience
|
||||||
|
- **Email Dependency**: If email IS working, participants still get email
|
||||||
|
- **Additional State**: No database or session changes needed
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Magic Link Logging
|
||||||
|
|
||||||
|
When a magic link is generated in development mode, the full URL is logged:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] DEV MODE: Magic link generated for participant user@example.com
|
||||||
|
[INFO] DEV MODE: Full magic link URL: https://sneaky-klaus.local/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Check
|
||||||
|
|
||||||
|
The logging is guarded by environment check:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
|
||||||
|
def should_log_dev_mode_links() -> bool:
|
||||||
|
"""
|
||||||
|
Check if development mode email logging is enabled.
|
||||||
|
|
||||||
|
CRITICAL: This must NEVER be True in production.
|
||||||
|
Only enable when explicitly running with FLASK_ENV=development.
|
||||||
|
"""
|
||||||
|
env = os.environ.get('FLASK_ENV', '').lower()
|
||||||
|
return env == 'development'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where Links Are Logged
|
||||||
|
|
||||||
|
Magic link URLs are logged during generation in the notification service:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def send_registration_confirmation(participant_id: int, token: str):
|
||||||
|
"""Send registration confirmation email with magic link."""
|
||||||
|
participant = Participant.query.get(participant_id)
|
||||||
|
exchange = Exchange.query.get(participant.exchange_id)
|
||||||
|
|
||||||
|
# Build magic link URL
|
||||||
|
magic_link_url = url_for(
|
||||||
|
'auth.participant_magic_link',
|
||||||
|
token=token,
|
||||||
|
_external=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Development mode: log the link for QA access
|
||||||
|
if should_log_dev_mode_links():
|
||||||
|
logger.info(f"DEV MODE: Magic link generated for participant {participant.email}")
|
||||||
|
logger.info(f"DEV MODE: Full magic link URL: {magic_link_url}")
|
||||||
|
|
||||||
|
# Send email (if email service available)
|
||||||
|
try:
|
||||||
|
send_email(
|
||||||
|
to=participant.email,
|
||||||
|
template='registration_confirmation',
|
||||||
|
variables={
|
||||||
|
'participant_name': participant.name,
|
||||||
|
'exchange_name': exchange.name,
|
||||||
|
'magic_link_url': magic_link_url,
|
||||||
|
'exchange_date': exchange.date,
|
||||||
|
'budget': exchange.budget
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except EmailServiceUnavailable:
|
||||||
|
# In development, logging fallback ensures we can still test
|
||||||
|
logger.info(f"Email service unavailable, but link logged for DEV MODE testing")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### CRITICAL: Production Safety
|
||||||
|
|
||||||
|
**This feature MUST NEVER be enabled in production.**
|
||||||
|
|
||||||
|
1. **Environment Check**: Code checks `FLASK_ENV` at runtime
|
||||||
|
2. **No Configuration Flag**: Not configurable; only environment-based
|
||||||
|
3. **Clear Warning**: Code comments explicitly state the security implication
|
||||||
|
4. **Immutable in Production**: Production deployments set `FLASK_ENV=production` or unset
|
||||||
|
5. **Audit Trail**: If logging were somehow enabled in production, it would be obvious in logs
|
||||||
|
|
||||||
|
### Attack Vector: Link Interception
|
||||||
|
|
||||||
|
**Threat**: Magic links logged in development contain full tokens. If logs are exposed, attacker can use tokens.
|
||||||
|
|
||||||
|
**Mitigations**:
|
||||||
|
- Development environment is not production
|
||||||
|
- Container logs are only accessible in development/QA networks
|
||||||
|
- Tokens have 1-hour expiration
|
||||||
|
- Single-use enforcement prevents replay
|
||||||
|
- In production, this feature is completely disabled
|
||||||
|
|
||||||
|
### Attack Vector: Log Exposure
|
||||||
|
|
||||||
|
**Threat**: Production logs somehow exposed and contain dev-mode links.
|
||||||
|
|
||||||
|
**Mitigations**:
|
||||||
|
- Feature only enabled when `FLASK_ENV=development`
|
||||||
|
- Production environments never set this variable
|
||||||
|
- If accidentally enabled, logs clearly show "DEV MODE" prefix
|
||||||
|
- Log aggregation systems should filter dev logs from production
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_dev_mode_logging_disabled_in_production():
|
||||||
|
"""Ensure development mode logging is never enabled in production."""
|
||||||
|
# Simulate production environment
|
||||||
|
os.environ['FLASK_ENV'] = 'production'
|
||||||
|
|
||||||
|
# Development mode feature should be disabled
|
||||||
|
assert should_log_dev_mode_links() is False
|
||||||
|
|
||||||
|
def test_dev_mode_logging_enabled_in_development():
|
||||||
|
"""Ensure development mode logging is enabled in development."""
|
||||||
|
# Simulate development environment
|
||||||
|
os.environ['FLASK_ENV'] = 'development'
|
||||||
|
|
||||||
|
# Development mode feature should be enabled
|
||||||
|
assert should_log_dev_mode_links() is True
|
||||||
|
|
||||||
|
def test_magic_link_logged_in_dev_mode(caplog):
|
||||||
|
"""Test that magic links are logged in development mode."""
|
||||||
|
os.environ['FLASK_ENV'] = 'development'
|
||||||
|
|
||||||
|
participant = create_test_participant()
|
||||||
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
||||||
|
|
||||||
|
# Send confirmation (would log the link)
|
||||||
|
send_registration_confirmation(participant.id, token)
|
||||||
|
|
||||||
|
# Check log contains DEV MODE indicator and URL
|
||||||
|
assert 'DEV MODE' in caplog.text
|
||||||
|
assert 'magic_link_url' in caplog.text or 'Full magic link URL' in caplog.text
|
||||||
|
|
||||||
|
def test_magic_link_not_logged_in_production(caplog):
|
||||||
|
"""Test that magic links are NOT logged in production."""
|
||||||
|
os.environ['FLASK_ENV'] = 'production'
|
||||||
|
|
||||||
|
participant = create_test_participant()
|
||||||
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
||||||
|
|
||||||
|
# Send confirmation (should NOT log the link)
|
||||||
|
send_registration_confirmation(participant.id, token)
|
||||||
|
|
||||||
|
# Check log does NOT contain DEV MODE indicator
|
||||||
|
assert 'DEV MODE' not in caplog.text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_qa_workflow_with_dev_mode_logging():
|
||||||
|
"""
|
||||||
|
Simulate QA workflow:
|
||||||
|
1. Register participant
|
||||||
|
2. Retrieve magic link from logs
|
||||||
|
3. Use link to authenticate
|
||||||
|
"""
|
||||||
|
os.environ['FLASK_ENV'] = 'development'
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
# Step 1: Register participant
|
||||||
|
response = client.post('/exchange/test-slug/register', data={
|
||||||
|
'name': 'QA Tester',
|
||||||
|
'email': 'qa@example.com',
|
||||||
|
'gift_ideas': 'Testing gifts',
|
||||||
|
'csrf_token': get_csrf_token()
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Step 2: Extract magic link from logs
|
||||||
|
# In real test, would use caplog fixture or retrieve from log file
|
||||||
|
# For this example, extract from app logs
|
||||||
|
logs = get_application_logs()
|
||||||
|
magic_link_match = re.search(r'DEV MODE: Full magic link URL: ([^ ]+)', logs)
|
||||||
|
assert magic_link_match is not None
|
||||||
|
|
||||||
|
magic_link_url = magic_link_match.group(1)
|
||||||
|
# Extract token from URL
|
||||||
|
token = magic_link_url.split('/magic/')[-1]
|
||||||
|
|
||||||
|
# Step 3: Use magic link to authenticate
|
||||||
|
response = client.get(f'/auth/participant/magic/{token}')
|
||||||
|
|
||||||
|
# Should redirect to dashboard
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert 'dashboard' in response.location
|
||||||
|
|
||||||
|
# Session should be created
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess['user_type'] == 'participant'
|
||||||
|
assert sess['user_id'] is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
## How QA Uses Dev Mode
|
||||||
|
|
||||||
|
### For Podman Containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a participant via web UI
|
||||||
|
# Then retrieve the magic link from container logs:
|
||||||
|
podman logs sneaky-klaus-qa | grep "DEV MODE"
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# [INFO] DEV MODE: Magic link generated for participant alice@example.com
|
||||||
|
# [INFO] DEV MODE: Full magic link URL: https://localhost:5000/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5
|
||||||
|
|
||||||
|
# Copy the URL and paste into browser or use curl:
|
||||||
|
curl https://localhost:5000/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Flask development server with FLASK_ENV=development
|
||||||
|
FLASK_ENV=development uv run flask run
|
||||||
|
|
||||||
|
# Register a participant via web UI
|
||||||
|
# Magic link appears in terminal output:
|
||||||
|
# [INFO] DEV MODE: Magic link generated for participant alice@example.com
|
||||||
|
# [INFO] DEV MODE: Full magic link URL: http://localhost:5000/auth/participant/magic/...
|
||||||
|
|
||||||
|
# Copy and use the link
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No configuration needed. Feature is automatically enabled when:
|
||||||
|
|
||||||
|
```
|
||||||
|
FLASK_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
Feature is automatically disabled when:
|
||||||
|
|
||||||
|
```
|
||||||
|
FLASK_ENV=production
|
||||||
|
# or unset (defaults to production behavior)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Enhanced Dev Tools
|
||||||
|
|
||||||
|
If QA requests additional features:
|
||||||
|
|
||||||
|
1. **Dev-Only Endpoint**: Create `/dev/magic-links` endpoint that lists recent links (only in development)
|
||||||
|
2. **Email Override**: Allow configuration to always log links even if email sends successfully
|
||||||
|
3. **Log Export**: Endpoint to export recent logs as JSON for automated testing
|
||||||
|
|
||||||
|
### Production Monitoring
|
||||||
|
|
||||||
|
Consider monitoring/alerting if dev mode logging somehow gets enabled in production:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if should_log_dev_mode_links() and in_production():
|
||||||
|
send_alert("CRITICAL: Dev mode logging enabled in production!")
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-0002: Authentication Strategy](./0002-authentication-strategy.md)
|
||||||
|
- [Participant Auth Component Design](../designs/v0.2.0/components/participant-auth.md)
|
||||||
|
- [Flask Environment and Configuration](https://flask.palletsprojects.com/en/latest/config/)
|
||||||
317
docs/decisions/0005-database-migrations.md
Normal file
317
docs/decisions/0005-database-migrations.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# 0005. Database Migrations with Alembic
|
||||||
|
|
||||||
|
Date: 2025-12-22
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Sneaky Klaus uses SQLite as its database (see ADR-0001). As the application evolves, the database schema needs to change to support new features. There are two primary approaches to managing database schema:
|
||||||
|
|
||||||
|
1. **db.create_all()**: SQLAlchemy's create_all() method creates tables based on current model definitions. Simple but has critical limitations:
|
||||||
|
- Cannot modify existing tables (add/remove columns, change types)
|
||||||
|
- Cannot migrate data during schema changes
|
||||||
|
- No version tracking or rollback capability
|
||||||
|
- Unsafe for databases with existing data
|
||||||
|
|
||||||
|
2. **Schema Migrations**: Tools like Alembic track schema changes as versioned migration files:
|
||||||
|
- Supports incremental schema changes (add columns, modify constraints, etc.)
|
||||||
|
- Enables data migrations during schema evolution
|
||||||
|
- Provides version tracking and rollback capability
|
||||||
|
- Safe for production databases with existing data
|
||||||
|
|
||||||
|
Key considerations:
|
||||||
|
|
||||||
|
- Phase 1 (v0.1.0) established Admin and Exchange models
|
||||||
|
- Phase 2 (v0.2.0) adds Participant and MagicToken models
|
||||||
|
- Future phases will continue evolving the schema
|
||||||
|
- Self-hosted deployments may have persistent data from day one
|
||||||
|
- Users may skip versions or upgrade incrementally
|
||||||
|
|
||||||
|
The question is: when should we start using proper database migrations?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will use **Alembic for all database schema changes starting from Phase 2 (v0.2.0)** onward.
|
||||||
|
|
||||||
|
Specifically:
|
||||||
|
|
||||||
|
1. **Alembic is already configured** in the codebase (alembic.ini, migrations/ directory)
|
||||||
|
2. **An initial migration already exists** for Admin and Exchange models (created in Phase 1)
|
||||||
|
3. **All new models and schema changes** will be managed through Alembic migrations
|
||||||
|
4. **db.create_all() must not be used** for schema creation in production environments
|
||||||
|
|
||||||
|
### Migration Workflow
|
||||||
|
|
||||||
|
For all schema changes:
|
||||||
|
|
||||||
|
1. Modify SQLAlchemy models in `src/models/`
|
||||||
|
2. Generate migration: `uv run alembic revision --autogenerate -m "description"`
|
||||||
|
3. Review the generated migration file in `migrations/versions/`
|
||||||
|
4. Test the migration (upgrade and downgrade paths)
|
||||||
|
5. Commit the migration file with model changes
|
||||||
|
6. Apply in deployments: `uv run alembic upgrade head`
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
Migration messages should be:
|
||||||
|
- Descriptive and imperative: "Add Participant model", "Add email index to Participant"
|
||||||
|
- Under 80 characters
|
||||||
|
- Use lowercase except for model/table names
|
||||||
|
- Examples:
|
||||||
|
- "Add Participant and MagicToken models"
|
||||||
|
- "Add withdrawn_at column to Participant"
|
||||||
|
- "Create composite index on exchange_id and email"
|
||||||
|
|
||||||
|
### Testing Requirements
|
||||||
|
|
||||||
|
Every migration must be tested for:
|
||||||
|
1. **Upgrade path**: `alembic upgrade head` succeeds
|
||||||
|
2. **Downgrade path**: `alembic downgrade -1` and `alembic upgrade head` both succeed
|
||||||
|
3. **Schema correctness**: Database schema matches SQLAlchemy model definitions
|
||||||
|
4. **Application compatibility**: All tests pass after migration
|
||||||
|
|
||||||
|
### Handling Existing Databases
|
||||||
|
|
||||||
|
For databases created with db.create_all() before migrations were established:
|
||||||
|
|
||||||
|
**Option 1 - Stamp (preserves data)**:
|
||||||
|
```bash
|
||||||
|
uv run alembic stamp head
|
||||||
|
```
|
||||||
|
This marks the database as being at the current migration version without running migrations.
|
||||||
|
|
||||||
|
**Option 2 - Recreate (development only)**:
|
||||||
|
```bash
|
||||||
|
rm data/sneaky-klaus.db
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
This creates a fresh database from migrations. Only suitable for development.
|
||||||
|
|
||||||
|
### Removing db.create_all()
|
||||||
|
|
||||||
|
The `db.create_all()` call currently in `src/app.py` should be:
|
||||||
|
- Removed from production code paths
|
||||||
|
- Only used in test fixtures where appropriate
|
||||||
|
- Never used for schema initialization in deployments
|
||||||
|
|
||||||
|
Production deployments must use `alembic upgrade head` for schema initialization and updates.
|
||||||
|
|
||||||
|
### Automatic Migrations for Self-Hosted Deployments
|
||||||
|
|
||||||
|
For self-hosted deployments using containers, migrations must be applied automatically when the container starts. This ensures that:
|
||||||
|
- Users pulling new container images automatically get schema updates
|
||||||
|
- No manual migration commands required
|
||||||
|
- Schema is always in sync with application code
|
||||||
|
- First-run deployments get proper schema initialization
|
||||||
|
|
||||||
|
**Implementation Approach: Container Entrypoint Script**
|
||||||
|
|
||||||
|
An entrypoint script runs `alembic upgrade head` before starting the application server. This approach is chosen because:
|
||||||
|
- **Timing**: Migrations run before application starts, avoiding race conditions
|
||||||
|
- **Separation of concerns**: Database initialization is separate from application startup
|
||||||
|
- **Clear error handling**: Migration failures prevent application startup
|
||||||
|
- **Standard pattern**: Common practice for containerized applications with databases
|
||||||
|
- **Works with gunicorn**: Gunicorn workers don't need to coordinate migrations
|
||||||
|
|
||||||
|
**Entrypoint Script Responsibilities**:
|
||||||
|
1. Run `alembic upgrade head` to apply all pending migrations
|
||||||
|
2. Log migration status (success or failure)
|
||||||
|
3. Exit with error if migrations fail (preventing container startup)
|
||||||
|
4. Start application server (gunicorn) if migrations succeed
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
if uv run alembic upgrade head; then
|
||||||
|
echo "Database migrations completed successfully"
|
||||||
|
else
|
||||||
|
echo "ERROR: Database migration failed!"
|
||||||
|
echo "Please check the logs above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting application server..."
|
||||||
|
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling**:
|
||||||
|
- Migration failures are logged to stderr
|
||||||
|
- Container exits with code 1 on migration failure
|
||||||
|
- Container orchestrator (podman/docker compose) will show failed state
|
||||||
|
- Users can inspect logs with `podman logs sneaky-klaus` or `docker logs sneaky-klaus`
|
||||||
|
|
||||||
|
**Containerfile Changes**:
|
||||||
|
- Copy entrypoint script: `COPY entrypoint.sh /app/entrypoint.sh`
|
||||||
|
- Make executable: `RUN chmod +x /app/entrypoint.sh`
|
||||||
|
- Change CMD to use entrypoint: `CMD ["/app/entrypoint.sh"]`
|
||||||
|
|
||||||
|
**First-Run Initialization**:
|
||||||
|
When no database exists, `alembic upgrade head` will:
|
||||||
|
1. Create the database file (SQLite)
|
||||||
|
2. Create the `alembic_version` table to track migration state
|
||||||
|
3. Run all migrations from scratch
|
||||||
|
4. Leave database in up-to-date state
|
||||||
|
|
||||||
|
**Update Scenarios**:
|
||||||
|
When updating to a new container image with schema changes:
|
||||||
|
1. Container starts and runs entrypoint script
|
||||||
|
2. Alembic detects current schema version from `alembic_version` table
|
||||||
|
3. Applies only new migrations (incremental upgrade)
|
||||||
|
4. Application starts with updated schema
|
||||||
|
|
||||||
|
**Development Workflow**:
|
||||||
|
For local development (non-containerized), developers continue to run migrations manually:
|
||||||
|
```bash
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives developers explicit control over when migrations run during development.
|
||||||
|
|
||||||
|
**Alternative Considered: Application Startup Migrations**
|
||||||
|
|
||||||
|
Running migrations in `src/app.py` during Flask application startup was considered but rejected:
|
||||||
|
- **Race conditions**: Multiple gunicorn workers could try to run migrations simultaneously
|
||||||
|
- **Locking complexity**: Would need migration locks to prevent concurrent runs
|
||||||
|
- **Startup delays**: Application health checks might fail during migration
|
||||||
|
- **Error visibility**: Migration failures less visible than container startup failures
|
||||||
|
- **Not idiomatic**: Flask apps typically don't modify their own schema on startup
|
||||||
|
|
||||||
|
The entrypoint script approach is simpler, safer, and more aligned with containerized deployment best practices.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Safe schema evolution**: Can modify existing tables without data loss
|
||||||
|
- **Version control**: Schema changes tracked in git alongside code changes
|
||||||
|
- **Rollback capability**: Can revert problematic schema changes
|
||||||
|
- **Data migrations**: Can transform data during schema changes (e.g., populate new required columns)
|
||||||
|
- **Production ready**: Proper migration strategy from the start avoids migration debt
|
||||||
|
- **Clear deployment process**: `alembic upgrade head` is explicit and auditable
|
||||||
|
- **Multi-environment support**: Same migrations work across dev, staging, and production
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **Additional complexity**: Developers must learn Alembic workflow
|
||||||
|
- **Migration review required**: Auto-generated migrations must be reviewed for correctness
|
||||||
|
- **Migration discipline needed**: Schema changes require creating and testing migrations
|
||||||
|
- **Downgrade path maintenance**: Must write downgrade logic for each migration
|
||||||
|
- **Linear migration history**: Merge conflicts in migrations can require rebasing
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- **Learning curve**: Alembic has good documentation but requires initial learning
|
||||||
|
- **Migration conflicts**: Multiple developers changing schema simultaneously may need coordination
|
||||||
|
- **Test database setup**: Tests may need to apply migrations rather than using create_all()
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Phase 2 Implementation
|
||||||
|
|
||||||
|
For Phase 2 (v0.2.0), the developer should:
|
||||||
|
|
||||||
|
1. Create Participant and MagicToken models in `src/models/`
|
||||||
|
2. Generate migration:
|
||||||
|
```bash
|
||||||
|
uv run alembic revision --autogenerate -m "Add Participant and MagicToken models"
|
||||||
|
```
|
||||||
|
3. Review the generated migration file:
|
||||||
|
- Verify all new tables, columns, and indexes are included
|
||||||
|
- Check foreign key constraints are correct
|
||||||
|
- Ensure indexes are created for performance-critical queries
|
||||||
|
4. Test the migration:
|
||||||
|
```bash
|
||||||
|
# Test upgrade
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
# Test downgrade (optional but recommended)
|
||||||
|
uv run alembic downgrade -1
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
5. Run application tests to verify compatibility
|
||||||
|
6. Commit migration file with model changes
|
||||||
|
|
||||||
|
### Migration File Structure
|
||||||
|
|
||||||
|
Migration files are in `migrations/versions/` and follow this structure:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Add Participant and MagicToken models
|
||||||
|
|
||||||
|
Revision ID: abc123def456
|
||||||
|
Revises: eeff6e1a89cd
|
||||||
|
Create Date: 2025-12-22 10:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = 'abc123def456'
|
||||||
|
down_revision = 'eeff6e1a89cd'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Schema changes for upgrade
|
||||||
|
op.create_table('participant',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
# ... other columns
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Schema changes for rollback
|
||||||
|
op.drop_table('participant')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alembic Configuration
|
||||||
|
|
||||||
|
Alembic is configured via `alembic.ini`:
|
||||||
|
- Migration directory: `migrations/`
|
||||||
|
- SQLAlchemy URL: Configured dynamically from Flask config in `migrations/env.py`
|
||||||
|
- Auto-generate support: Enabled
|
||||||
|
|
||||||
|
### Documentation Updates
|
||||||
|
|
||||||
|
The following documentation has been updated to reflect this decision:
|
||||||
|
- Phase 2 Implementation Decisions (section 9.1)
|
||||||
|
- Data Model v0.2.0 (Migration Strategy section)
|
||||||
|
- System Architecture Overview v0.2.0 (Database Layer section)
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Continue using db.create_all()
|
||||||
|
|
||||||
|
**Rejected**: While simpler initially, db.create_all() cannot handle schema evolution. Since:
|
||||||
|
- Alembic infrastructure already exists in the codebase
|
||||||
|
- We expect ongoing schema evolution across multiple phases
|
||||||
|
- Self-hosted deployments may have persistent data
|
||||||
|
- Production-ready approach prevents migration debt
|
||||||
|
|
||||||
|
Starting with Alembic now is the right choice despite the added complexity.
|
||||||
|
|
||||||
|
### Manual SQL migrations
|
||||||
|
|
||||||
|
**Rejected**: Writing raw SQL migrations is error-prone and doesn't integrate with SQLAlchemy models. Alembic's autogenerate feature significantly reduces migration creation effort while maintaining safety.
|
||||||
|
|
||||||
|
### Django-style migrations
|
||||||
|
|
||||||
|
**Rejected**: Django's migration system is tightly coupled to Django ORM. Alembic is the standard for SQLAlchemy-based applications and integrates well with Flask.
|
||||||
|
|
||||||
|
### Defer migrations until schema is stable
|
||||||
|
|
||||||
|
**Rejected**: The schema will evolve continuously as new features are added. Deferring migrations creates migration debt and makes it harder to support existing deployments. Starting with migrations from Phase 2 establishes good patterns early.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Alembic documentation: https://alembic.sqlalchemy.org/
|
||||||
|
- SQLAlchemy documentation: https://docs.sqlalchemy.org/
|
||||||
|
- ADR-0001: Core Technology Stack
|
||||||
|
- Phase 2 Implementation Decisions (section 9.1)
|
||||||
|
- Data Model v0.2.0
|
||||||
266
docs/designs/v0.2.0/MIGRATION-FLOW.md
Normal file
266
docs/designs/v0.2.0/MIGRATION-FLOW.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Database Migration Flow
|
||||||
|
|
||||||
|
## Container Startup Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ User runs: podman run sneaky-klaus │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Container starts entrypoint.sh │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Step 1: Run "uv run alembic upgrade head" │
|
||||||
|
│ │
|
||||||
|
│ Alembic checks /app/data/sneaky-klaus.db │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Database exists │ │ No database │
|
||||||
|
│ (update scenario) │ │ (first run) │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Read alembic_version │ │ Create database file │
|
||||||
|
│ table │ │ │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ Apply only new │ │ Run all migrations │
|
||||||
|
│ migrations │ │ from scratch │
|
||||||
|
│ (incremental) │ │ │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Success │ │ Failure │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Log success │ │ Log error │
|
||||||
|
│ message │ │ Exit code 1 │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Step 2: Start │ │ Container stops │
|
||||||
|
│ gunicorn │ │ (failed state) │
|
||||||
|
│ │ │ │
|
||||||
|
│ Application │ │ User checks logs │
|
||||||
|
│ ready to serve │ │ to debug │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Fresh Installation (First Run)
|
||||||
|
|
||||||
|
```
|
||||||
|
User action: podman run sneaky-klaus:v0.2.0
|
||||||
|
|
||||||
|
Container startup:
|
||||||
|
1. entrypoint.sh executes
|
||||||
|
2. alembic upgrade head runs
|
||||||
|
- No database file exists
|
||||||
|
- Creates /app/data/sneaky-klaus.db
|
||||||
|
- Creates alembic_version table
|
||||||
|
- Runs migration eeff6e1a89cd (Admin, Exchange)
|
||||||
|
- Runs migration abc123def456 (Participant, MagicToken)
|
||||||
|
- Sets current version: abc123def456
|
||||||
|
3. gunicorn starts
|
||||||
|
4. Application ready
|
||||||
|
|
||||||
|
Result: Fresh database with all tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Update from v0.1.0 to v0.2.0
|
||||||
|
|
||||||
|
```
|
||||||
|
User action:
|
||||||
|
1. podman pull sneaky-klaus:v0.2.0
|
||||||
|
2. podman stop sneaky-klaus
|
||||||
|
3. podman rm sneaky-klaus
|
||||||
|
4. podman run sneaky-klaus:v0.2.0 (same volume)
|
||||||
|
|
||||||
|
Container startup:
|
||||||
|
1. entrypoint.sh executes
|
||||||
|
2. alembic upgrade head runs
|
||||||
|
- Database file exists
|
||||||
|
- Reads alembic_version table
|
||||||
|
- Current version: eeff6e1a89cd
|
||||||
|
- Detects new migration: abc123def456
|
||||||
|
- Runs migration abc123def456 (adds Participant, MagicToken)
|
||||||
|
- Updates current version: abc123def456
|
||||||
|
3. gunicorn starts
|
||||||
|
4. Application ready
|
||||||
|
|
||||||
|
Result: Updated database with new tables, existing data preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Already Up-to-Date
|
||||||
|
|
||||||
|
```
|
||||||
|
User action: podman restart sneaky-klaus
|
||||||
|
|
||||||
|
Container startup:
|
||||||
|
1. entrypoint.sh executes
|
||||||
|
2. alembic upgrade head runs
|
||||||
|
- Database file exists
|
||||||
|
- Reads alembic_version table
|
||||||
|
- Current version: abc123def456
|
||||||
|
- No new migrations to apply
|
||||||
|
- Logs "Already at head"
|
||||||
|
3. gunicorn starts
|
||||||
|
4. Application ready
|
||||||
|
|
||||||
|
Result: No changes, fast startup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Migration Failure
|
||||||
|
|
||||||
|
```
|
||||||
|
User action: podman run sneaky-klaus:v0.3.0 (hypothetical buggy migration)
|
||||||
|
|
||||||
|
Container startup:
|
||||||
|
1. entrypoint.sh executes
|
||||||
|
2. alembic upgrade head runs
|
||||||
|
- Database file exists
|
||||||
|
- Current version: abc123def456
|
||||||
|
- Attempts new migration: xyz789bad000
|
||||||
|
- Migration fails (SQL error, constraint violation, etc.)
|
||||||
|
- Alembic rolls back transaction
|
||||||
|
- Returns exit code 1
|
||||||
|
3. entrypoint.sh detects failure
|
||||||
|
- Logs error message
|
||||||
|
- Exits with code 1
|
||||||
|
4. Container stops (failed state)
|
||||||
|
|
||||||
|
Result: Database unchanged, container not running
|
||||||
|
User action: Check logs, report bug, or fix database manually
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison: Manual vs Automatic Migrations
|
||||||
|
|
||||||
|
### Manual Migration Workflow (without this feature)
|
||||||
|
|
||||||
|
```
|
||||||
|
User workflow:
|
||||||
|
1. podman pull sneaky-klaus:v0.2.0
|
||||||
|
2. podman stop sneaky-klaus
|
||||||
|
3. podman exec sneaky-klaus bash ← Extra step
|
||||||
|
4. uv run alembic upgrade head ← Manual command
|
||||||
|
5. exit ← Extra step
|
||||||
|
6. podman start sneaky-klaus
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
- Requires command-line knowledge
|
||||||
|
- Easy to forget
|
||||||
|
- Error-prone
|
||||||
|
- Not friendly for non-technical users
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic Migration Workflow (with this feature)
|
||||||
|
|
||||||
|
```
|
||||||
|
User workflow:
|
||||||
|
1. podman pull sneaky-klaus:v0.2.0
|
||||||
|
2. podman stop sneaky-klaus
|
||||||
|
3. podman rm sneaky-klaus
|
||||||
|
4. podman run sneaky-klaus:v0.2.0
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Simple, standard container workflow
|
||||||
|
- Cannot forget to run migrations
|
||||||
|
- Migrations guaranteed to run before app starts
|
||||||
|
- Self-hosted friendly
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure After Implementation
|
||||||
|
|
||||||
|
```
|
||||||
|
sneaky-klaus/
|
||||||
|
├── entrypoint.sh ← NEW: Migration + startup script
|
||||||
|
├── Containerfile ← MODIFIED: Use entrypoint
|
||||||
|
├── src/
|
||||||
|
│ └── app.py ← MODIFIED: Remove db.create_all()
|
||||||
|
├── migrations/
|
||||||
|
│ ├── env.py ← (existing)
|
||||||
|
│ └── versions/
|
||||||
|
│ ├── eeff6e1a89cd_....py ← (existing)
|
||||||
|
│ └── abc123def456_....py ← NEW: Phase 2 migration
|
||||||
|
└── alembic.ini ← (existing)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developer vs Production Workflows
|
||||||
|
|
||||||
|
### Developer Workflow (Local Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Developer makes schema change
|
||||||
|
1. Edit src/models/participant.py
|
||||||
|
2. uv run alembic revision --autogenerate -m "Add Participant model"
|
||||||
|
3. Review generated migration file
|
||||||
|
4. uv run alembic upgrade head ← Manual migration
|
||||||
|
5. uv run pytest ← Test
|
||||||
|
6. git add migrations/versions/...
|
||||||
|
7. git commit -m "feat: add Participant model"
|
||||||
|
```
|
||||||
|
|
||||||
|
Developers retain explicit control over when migrations run.
|
||||||
|
|
||||||
|
### Production Workflow (Container Deployment)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Self-hosted user updates to new version
|
||||||
|
1. podman pull sneaky-klaus:v0.2.0
|
||||||
|
2. podman-compose down
|
||||||
|
3. podman-compose up -d ← Migrations run automatically
|
||||||
|
|
||||||
|
# No manual migration step needed
|
||||||
|
```
|
||||||
|
|
||||||
|
Users get automatic, safe migrations without extra commands.
|
||||||
|
|
||||||
|
## Security & Safety Considerations
|
||||||
|
|
||||||
|
### Why This is Safe
|
||||||
|
|
||||||
|
1. **Atomic migrations**: Alembic uses transactions (rollback on failure)
|
||||||
|
2. **Version tracking**: alembic_version table prevents re-running migrations
|
||||||
|
3. **Fail-safe**: Container won't start if migration fails
|
||||||
|
4. **No data loss**: Migrations are additive (add tables/columns)
|
||||||
|
5. **Tested**: All migrations tested before release
|
||||||
|
|
||||||
|
### What Could Go Wrong
|
||||||
|
|
||||||
|
1. **Migration bug**: Bad migration could fail or corrupt data
|
||||||
|
- **Mitigation**: Thorough testing of migrations before release
|
||||||
|
- **Recovery**: Database backup (future enhancement)
|
||||||
|
|
||||||
|
2. **Permission issue**: Container can't write to database file
|
||||||
|
- **Mitigation**: Volume permissions documentation
|
||||||
|
- **Recovery**: Fix volume permissions and restart
|
||||||
|
|
||||||
|
3. **Disk full**: No space for database changes
|
||||||
|
- **Mitigation**: Health checks and monitoring
|
||||||
|
- **Recovery**: Free up disk space and restart
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-0005: Database Migrations with Alembic
|
||||||
|
- Automatic Migration Implementation Guide
|
||||||
|
- Phase 2 Implementation Decisions (Section 9.2)
|
||||||
1514
docs/designs/v0.2.0/api-spec.md
Normal file
1514
docs/designs/v0.2.0/api-spec.md
Normal file
File diff suppressed because it is too large
Load Diff
395
docs/designs/v0.2.0/automatic-migration-implementation.md
Normal file
395
docs/designs/v0.2.0/automatic-migration-implementation.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# Automatic Migration Implementation Guide
|
||||||
|
|
||||||
|
**Version**: 0.2.0
|
||||||
|
**Date**: 2025-12-22
|
||||||
|
**Status**: Implementation Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document specifies how to implement automatic database migrations for containerized deployments of Sneaky Klaus. When self-hosted users pull a new container image with schema changes, migrations must be applied automatically without manual intervention.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Current State**: Container runs gunicorn directly; no automatic migrations
|
||||||
|
- **Problem**: Users must manually run `alembic upgrade head` after pulling new images
|
||||||
|
- **Solution**: Container entrypoint script applies migrations before starting app
|
||||||
|
- **Reference**: ADR-0005 "Automatic Migrations for Self-Hosted Deployments"
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Create Entrypoint Script
|
||||||
|
|
||||||
|
Create `/home/phil/Projects/sneaky-klaus/entrypoint.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
if uv run alembic upgrade head; then
|
||||||
|
echo "Database migrations completed successfully"
|
||||||
|
else
|
||||||
|
echo "ERROR: Database migration failed!"
|
||||||
|
echo "Please check the logs above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting application server..."
|
||||||
|
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points**:
|
||||||
|
- `set -e`: Ensures script exits immediately if any command fails
|
||||||
|
- Migrations run before gunicorn starts
|
||||||
|
- `exec` replaces the shell process with gunicorn (PID 1 becomes gunicorn for proper signal handling)
|
||||||
|
- Clear logging for debugging
|
||||||
|
- Exit code 1 on migration failure prevents container from starting
|
||||||
|
|
||||||
|
### Step 2: Update Containerfile
|
||||||
|
|
||||||
|
Modify `/home/phil/Projects/sneaky-klaus/Containerfile`:
|
||||||
|
|
||||||
|
**Add after line 29 (after copying application code)**:
|
||||||
|
```dockerfile
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh ./
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace line 51 (CMD line)**:
|
||||||
|
|
||||||
|
From:
|
||||||
|
```dockerfile
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "main:app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```dockerfile
|
||||||
|
CMD ["./entrypoint.sh"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full context** (lines 27-52 after changes):
|
||||||
|
```dockerfile
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY migrations/ ./migrations/
|
||||||
|
COPY alembic.ini main.py ./
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh ./
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
|
# Create data directory and set permissions
|
||||||
|
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
|
# Run with entrypoint script
|
||||||
|
CMD ["./entrypoint.sh"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Remove db.create_all() from Application
|
||||||
|
|
||||||
|
Modify `/home/phil/Projects/sneaky-klaus/src/app.py`:
|
||||||
|
|
||||||
|
**Replace lines 74-77**:
|
||||||
|
|
||||||
|
From:
|
||||||
|
```python
|
||||||
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
|
with app.app_context():
|
||||||
|
from src.models import Admin, Exchange, RateLimit # noqa: F401
|
||||||
|
|
||||||
|
db.create_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```python
|
||||||
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
|
with app.app_context():
|
||||||
|
from src.models import Admin, Exchange, RateLimit # noqa: F401
|
||||||
|
# Schema managed by Alembic migrations (applied via entrypoint script)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Migrations are now handled by entrypoint script
|
||||||
|
- `db.create_all()` conflicts with migration-based schema management
|
||||||
|
- `db.create_all()` cannot handle schema changes (only creates missing tables)
|
||||||
|
- ADR-0005 explicitly requires migrations for production
|
||||||
|
|
||||||
|
### Step 4: Copy uv to Runtime Stage
|
||||||
|
|
||||||
|
The entrypoint script uses `uv run alembic`, so we need `uv` available in the runtime stage.
|
||||||
|
|
||||||
|
Modify `/home/phil/Projects/sneaky-klaus/Containerfile`:
|
||||||
|
|
||||||
|
**Add after line 23 (after copying .venv from builder)**:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Copy uv for running alembic in entrypoint
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full context** (lines 15-30 after change):
|
||||||
|
```dockerfile
|
||||||
|
# Runtime stage - minimal image
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash appuser
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
# Copy uv for running alembic in entrypoint
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY migrations/ ./migrations/
|
||||||
|
COPY alembic.ini main.py ./
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test 1: First-Run Scenario
|
||||||
|
|
||||||
|
Verify migrations run when starting with no database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build new image
|
||||||
|
podman build -t sneaky-klaus:test .
|
||||||
|
|
||||||
|
# Remove any existing data volume
|
||||||
|
podman volume rm sneaky-klaus-data || true
|
||||||
|
|
||||||
|
# Start container
|
||||||
|
podman run -p 8000:8000 \
|
||||||
|
-e SECRET_KEY=test-secret-key \
|
||||||
|
-e APP_URL=http://localhost:8000 \
|
||||||
|
-v sneaky-klaus-data:/app/data \
|
||||||
|
--name sneaky-klaus-test \
|
||||||
|
sneaky-klaus:test
|
||||||
|
|
||||||
|
# Check logs - should see migration messages
|
||||||
|
podman logs sneaky-klaus-test
|
||||||
|
|
||||||
|
# Expected output:
|
||||||
|
# Running database migrations...
|
||||||
|
# INFO [alembic.runtime.migration] Context impl SQLiteImpl.
|
||||||
|
# INFO [alembic.runtime.migration] Will assume non-transactional DDL.
|
||||||
|
# INFO [alembic.runtime.migration] Running upgrade -> eeff6e1a89cd, initial schema with Admin and Exchange models
|
||||||
|
# Database migrations completed successfully
|
||||||
|
# Starting application server...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Update Scenario
|
||||||
|
|
||||||
|
Verify incremental migrations run when updating:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With container already running from Test 1, rebuild with new migration
|
||||||
|
# (After developer adds new migration in Phase 2)
|
||||||
|
podman build -t sneaky-klaus:test .
|
||||||
|
|
||||||
|
# Stop and remove old container
|
||||||
|
podman stop sneaky-klaus-test
|
||||||
|
podman rm sneaky-klaus-test
|
||||||
|
|
||||||
|
# Start new container with same data volume
|
||||||
|
podman run -p 8000:8000 \
|
||||||
|
-e SECRET_KEY=test-secret-key \
|
||||||
|
-e APP_URL=http://localhost:8000 \
|
||||||
|
-v sneaky-klaus-data:/app/data \
|
||||||
|
--name sneaky-klaus-test \
|
||||||
|
sneaky-klaus:test
|
||||||
|
|
||||||
|
# Check logs - should see new migration applied
|
||||||
|
podman logs sneaky-klaus-test
|
||||||
|
|
||||||
|
# Expected output:
|
||||||
|
# Running database migrations...
|
||||||
|
# INFO [alembic.runtime.migration] Context impl SQLiteImpl.
|
||||||
|
# INFO [alembic.runtime.migration] Will assume non-transactional DDL.
|
||||||
|
# INFO [alembic.runtime.migration] Running upgrade eeff6e1a89cd -> abc123def456, Add Participant and MagicToken models
|
||||||
|
# Database migrations completed successfully
|
||||||
|
# Starting application server...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Migration Failure Scenario
|
||||||
|
|
||||||
|
Verify container fails to start if migration fails:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manually corrupt the database or create an invalid migration
|
||||||
|
# Container should exit with error code 1
|
||||||
|
podman logs sneaky-klaus-test
|
||||||
|
|
||||||
|
# Expected output:
|
||||||
|
# Running database migrations...
|
||||||
|
# ERROR: Database migration failed!
|
||||||
|
# Please check the logs above for details.
|
||||||
|
|
||||||
|
# Container should not be running
|
||||||
|
podman ps -a | grep sneaky-klaus-test
|
||||||
|
# Should show "Exited (1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 4: Development Workflow
|
||||||
|
|
||||||
|
Verify developers can still run migrations manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local development (no container)
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
# Should work as before
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
### Phase 1: Update Current Release (v0.1.0)
|
||||||
|
|
||||||
|
Since v0.1.0 is already released, we should add automatic migrations to ensure users have this feature before Phase 2 ships:
|
||||||
|
|
||||||
|
1. Create entrypoint script
|
||||||
|
2. Update Containerfile
|
||||||
|
3. Remove `db.create_all()` from app.py
|
||||||
|
4. Test with existing v0.1.0 schema
|
||||||
|
5. Merge to `release/v0.1.0` branch
|
||||||
|
6. Build and tag new v0.1.0 patch release (v0.1.1)
|
||||||
|
7. Document in release notes
|
||||||
|
|
||||||
|
### Phase 2: Available for New Migrations (v0.2.0)
|
||||||
|
|
||||||
|
When Phase 2 development starts:
|
||||||
|
|
||||||
|
1. New Participant/MagicToken migrations will use automatic migration system
|
||||||
|
2. Test that migrations apply automatically on container startup
|
||||||
|
3. Document behavior in Phase 2 release notes
|
||||||
|
|
||||||
|
## Error Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Migration Fails
|
||||||
|
|
||||||
|
**Symptom**: Container exits immediately after startup
|
||||||
|
**User Action**:
|
||||||
|
1. Check logs: `podman logs sneaky-klaus`
|
||||||
|
2. Identify failing migration from Alembic output
|
||||||
|
3. Report issue or fix database manually
|
||||||
|
4. Restart container
|
||||||
|
|
||||||
|
**Prevention**: Thorough testing of migrations before release
|
||||||
|
|
||||||
|
### Scenario 2: Database File Permissions
|
||||||
|
|
||||||
|
**Symptom**: Alembic cannot write to database file
|
||||||
|
**Root Cause**: Volume mount permissions mismatch
|
||||||
|
**User Action**: Ensure data volume is writable by container user (UID 1000)
|
||||||
|
|
||||||
|
### Scenario 3: Missing uv Binary
|
||||||
|
|
||||||
|
**Symptom**: `/bin/bash: uv: command not found`
|
||||||
|
**Root Cause**: uv not copied to runtime stage
|
||||||
|
**Fix**: Verify Containerfile includes uv COPY step (Step 4 above)
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
### User Documentation
|
||||||
|
|
||||||
|
Update deployment documentation to explain:
|
||||||
|
- Migrations run automatically on container startup
|
||||||
|
- How to check migration logs
|
||||||
|
- What to do if migrations fail
|
||||||
|
- No manual migration commands needed
|
||||||
|
|
||||||
|
### Developer Documentation
|
||||||
|
|
||||||
|
Update CLAUDE.md and development guide:
|
||||||
|
- Container entrypoint runs migrations
|
||||||
|
- Local development still uses manual `uv run alembic upgrade head`
|
||||||
|
- How to test migrations in container
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future phases:
|
||||||
|
|
||||||
|
### Backup Before Migration
|
||||||
|
|
||||||
|
Add automatic backup before running migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Backing up database..."
|
||||||
|
if [ -f /app/data/sneaky-klaus.db ]; then
|
||||||
|
cp /app/data/sneaky-klaus.db /app/data/sneaky-klaus.db.backup-$(date +%Y%m%d-%H%M%S)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
# ... rest of script
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Dry-Run Mode
|
||||||
|
|
||||||
|
Add environment variable for dry-run mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if [ "$MIGRATION_DRY_RUN" = "true" ]; then
|
||||||
|
echo "DRY RUN MODE: Showing pending migrations..."
|
||||||
|
uv run alembic current
|
||||||
|
uv run alembic heads
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Timeout
|
||||||
|
|
||||||
|
Add timeout to prevent hanging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
timeout 300 uv run alembic upgrade head || {
|
||||||
|
echo "ERROR: Migration timed out after 5 minutes"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These enhancements are not required for Phase 2 but could be considered for Phase 5 (Operations) or later.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-0005: Database Migrations with Alembic
|
||||||
|
- Phase 2 Implementation Decisions (Section 9.2)
|
||||||
|
- Alembic Documentation: https://alembic.sqlalchemy.org/
|
||||||
|
- Docker/Podman Entrypoint Best Practices
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
This implementation is complete when:
|
||||||
|
|
||||||
|
- [ ] `entrypoint.sh` created and executable
|
||||||
|
- [ ] Containerfile updated to use entrypoint script
|
||||||
|
- [ ] `uv` binary available in runtime stage
|
||||||
|
- [ ] `db.create_all()` removed from `src/app.py`
|
||||||
|
- [ ] First-run test passes (fresh database)
|
||||||
|
- [ ] Update test passes (existing database with new migration)
|
||||||
|
- [ ] Failure test passes (container exits on migration error)
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] Changes merged to release branch
|
||||||
862
docs/designs/v0.2.0/components/auth.md
Normal file
862
docs/designs/v0.2.0/components/auth.md
Normal 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">×</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)
|
||||||
1017
docs/designs/v0.2.0/components/background-jobs.md
Normal file
1017
docs/designs/v0.2.0/components/background-jobs.md
Normal file
File diff suppressed because it is too large
Load Diff
732
docs/designs/v0.2.0/components/matching.md
Normal file
732
docs/designs/v0.2.0/components/matching.md
Normal 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)
|
||||||
979
docs/designs/v0.2.0/components/notifications.md
Normal file
979
docs/designs/v0.2.0/components/notifications.md
Normal 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>© {{ 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)
|
||||||
1119
docs/designs/v0.2.0/components/participant-auth.md
Normal file
1119
docs/designs/v0.2.0/components/participant-auth.md
Normal file
File diff suppressed because it is too large
Load Diff
777
docs/designs/v0.2.0/data-model.md
Normal file
777
docs/designs/v0.2.0/data-model.md
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
# Data Model - v0.2.0
|
||||||
|
|
||||||
|
**Version**: 0.2.0
|
||||||
|
**Date**: 2025-12-22
|
||||||
|
**Status**: Phase 2 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.
|
||||||
|
|
||||||
|
**Phase 2 Additions**: This version fully implements the Participant and MagicToken tables that were designed in v0.1.0 but not yet utilized. These tables enable participant registration, magic link authentication, and participant sessions.
|
||||||
|
|
||||||
|
**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)
|
||||||
594
docs/designs/v0.2.0/overview.md
Normal file
594
docs/designs/v0.2.0/overview.md
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
# System Architecture Overview - v0.2.0
|
||||||
|
|
||||||
|
**Version**: 0.2.0
|
||||||
|
**Date**: 2025-12-22
|
||||||
|
**Status**: Phase 2 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.
|
||||||
|
|
||||||
|
**Phase 2 Scope**: This version adds participant registration, magic link authentication, and participant sessions to the MVP foundation established in v0.1.0.
|
||||||
|
|
||||||
|
## 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/)
|
||||||
933
docs/designs/v0.2.0/phase-2-implementation-decisions.md
Normal file
933
docs/designs/v0.2.0/phase-2-implementation-decisions.md
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
# Phase 2 Implementation Decisions
|
||||||
|
|
||||||
|
**Version**: 0.2.0
|
||||||
|
**Date**: 2025-12-22
|
||||||
|
**Status**: Architectural Guidance
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document provides architectural answers to implementation questions raised during Phase 2 design review. These decisions are based on existing codebase patterns, Flask/Python best practices, and the project's design principles (simplicity, self-host friendly, security).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Database Schema Decisions
|
||||||
|
|
||||||
|
### 1.1 MagicToken Foreign Key Constraints
|
||||||
|
|
||||||
|
**Question**: How to enforce that magic_link tokens have participant_id/exchange_id NOT NULL, while password_reset tokens have them NULL?
|
||||||
|
|
||||||
|
**Answer**: **Option A - Nullable columns with application-level validation**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- SQLite has limited CHECK constraint support for complex conditions
|
||||||
|
- Application-level validation is already the project pattern (see Exchange state transitions in `/home/phil/Projects/sneaky-klaus/src/models/exchange.py`)
|
||||||
|
- Simpler migration path (no separate tables)
|
||||||
|
- Token cleanup job handles both types uniformly
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
class MagicToken(db.Model):
|
||||||
|
# Columns are nullable
|
||||||
|
participant_id: Mapped[int | None] = mapped_column(Integer, ForeignKey('participant.id'), nullable=True)
|
||||||
|
exchange_id: Mapped[int | None] = mapped_column(Integer, ForeignKey('exchange.id'), nullable=True)
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
"""Validate foreign key requirements based on token type."""
|
||||||
|
if self.token_type == 'magic_link':
|
||||||
|
if not self.participant_id or not self.exchange_id:
|
||||||
|
raise ValueError("Magic link tokens require participant_id and exchange_id")
|
||||||
|
elif self.token_type == 'password_reset':
|
||||||
|
if self.participant_id or self.exchange_id:
|
||||||
|
raise ValueError("Password reset tokens cannot have participant_id or exchange_id")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Participant Email Normalization
|
||||||
|
|
||||||
|
**Question**: Should emails be normalized (lowercased) before storage?
|
||||||
|
|
||||||
|
**Answer**: **Yes - always normalize to lowercase before storage**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Existing admin login pattern already does this: `/home/phil/Projects/sneaky-klaus/src/routes/admin.py` line 40 `email = form.email.data.lower()`
|
||||||
|
- Consistency across admin and participant authentication
|
||||||
|
- Prevents duplicate registrations with different casing (Alice@example.com vs alice@example.com)
|
||||||
|
- Email comparison is case-insensitive per RFC 5321
|
||||||
|
|
||||||
|
**Implementation Pattern**:
|
||||||
|
```python
|
||||||
|
# During participant registration
|
||||||
|
email = form.email.data.lower().strip()
|
||||||
|
participant = Participant(
|
||||||
|
email=email,
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply normalization at these points:
|
||||||
|
- Participant registration form processing
|
||||||
|
- Request magic link form processing
|
||||||
|
- Any email lookup query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Session Table Management
|
||||||
|
|
||||||
|
**Question**: Confirm we're using Flask-Session's built-in table, not a custom Session model.
|
||||||
|
|
||||||
|
**Answer**: **Correct - use Flask-Session's managed table**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Current implementation already uses Flask-Session with SQLAlchemy backend: `/home/phil/Projects/sneaky-klaus/src/app.py` lines 54-56
|
||||||
|
- Flask-Session automatically creates and manages the `sessions` table
|
||||||
|
- No custom Session model needed
|
||||||
|
- v0.2.0 data model document explicitly states: "Session storage is managed by Flask-Session, which creates and manages its own session table."
|
||||||
|
|
||||||
|
**No action needed** - existing implementation is correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication & Session Decisions
|
||||||
|
|
||||||
|
### 2.1 Session Scoping Implementation
|
||||||
|
|
||||||
|
**Question**: When clicking magic link for different exchange, should we always replace, show warning, or support concurrent sessions?
|
||||||
|
|
||||||
|
**Answer**: **Option A - Always replace (as designed)**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ADR-0003 (Participant Session Scoping) explicitly documents this behavior
|
||||||
|
- Simplicity: no complex multi-session management
|
||||||
|
- Security: clear isolation between exchanges
|
||||||
|
- Existing admin session pattern: `/home/phil/Projects/sneaky-klaus/src/routes/admin.py` line 59 `session.clear()` before creating new session
|
||||||
|
|
||||||
|
**Participant session key naming**: Use `user_id` and `user_type` to match existing pattern from ADR-0002.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
def create_participant_session(participant_id: int, exchange_id: int):
|
||||||
|
"""Create Flask session for participant."""
|
||||||
|
session.clear() # Always replace existing session
|
||||||
|
session['user_id'] = participant_id
|
||||||
|
session['user_type'] = 'participant'
|
||||||
|
session['exchange_id'] = exchange_id
|
||||||
|
session.permanent = True
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do NOT use different key names** - admin uses `admin_id` for historical reasons (legacy pattern), but participants should use the standardized `user_id` + `user_type` pattern from ADR-0002.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Session Duration and Sliding Window
|
||||||
|
|
||||||
|
**Question**: Does Flask-Session automatically implement sliding window? Should admin and participant have different durations?
|
||||||
|
|
||||||
|
**Answer**: **Flask-Session implements sliding window automatically; use same 7-day duration for both**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Configuration already sets `SESSION_REFRESH_EACH_REQUEST = True` in `/home/phil/Projects/sneaky-klaus/src/config.py` (implied by defaults)
|
||||||
|
- Flask-Session extends session on each request when `session.permanent = True`
|
||||||
|
- ADR-0002 specifies 7-day sliding window for both admin and participants
|
||||||
|
- Existing admin login sets variable duration (7 days default, 30 days with remember_me) - but Phase 2 design specifies 7 days for participants
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- Admin sessions: Keep existing pattern (7 days default, 30 days with remember_me)
|
||||||
|
- Participant sessions: 7 days, no remember_me option
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In config.py - already correct
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
|
||||||
|
|
||||||
|
# Participant session creation
|
||||||
|
session.permanent = True # Enables sliding window
|
||||||
|
# No need to override PERMANENT_SESSION_LIFETIME for participants
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Email Integration Decisions
|
||||||
|
|
||||||
|
### 3.1 Resend SDK Integration
|
||||||
|
|
||||||
|
**Question**: Should we create EmailService class? Where to store templates? Use Jinja2?
|
||||||
|
|
||||||
|
**Answer**: **Create `src/services/email.py` with EmailService class; store templates in `templates/emails/`; yes, use Jinja2**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Service layer pattern separates concerns
|
||||||
|
- Existing project structure has `/home/phil/Projects/sneaky-klaus/src/utils/` for utilities, but email is a service (external integration)
|
||||||
|
- Jinja2 already used for web templates, reuse for email
|
||||||
|
- Phase 2 design specifies template paths: `templates/emails/participant/registration_confirmation.html`
|
||||||
|
|
||||||
|
**Directory Structure**:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
services/
|
||||||
|
__init__.py
|
||||||
|
email.py # EmailService class
|
||||||
|
templates/
|
||||||
|
emails/
|
||||||
|
participant/
|
||||||
|
registration_confirmation.html
|
||||||
|
registration_confirmation.txt # Plain text fallback
|
||||||
|
magic_link.html
|
||||||
|
magic_link.txt
|
||||||
|
admin/
|
||||||
|
password_reset.html
|
||||||
|
password_reset.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# src/services/email.py
|
||||||
|
from flask import render_template
|
||||||
|
import resend
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
resend.api_key = api_key
|
||||||
|
|
||||||
|
def send_registration_confirmation(self, participant_id: int, token: str):
|
||||||
|
# Load participant and exchange
|
||||||
|
# Render template
|
||||||
|
html = render_template('emails/participant/registration_confirmation.html', **variables)
|
||||||
|
text = render_template('emails/participant/registration_confirmation.txt', **variables)
|
||||||
|
# Send via Resend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Development Mode Email Logging
|
||||||
|
|
||||||
|
**Question**: Should dev mode check app.config['DEBUG'], FLASK_ENV, or both? Mock Resend API or attempt to send?
|
||||||
|
|
||||||
|
**Answer**: **Check FLASK_ENV only (not DEBUG); attempt to send, with logging as fallback**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ADR-0004 (Development Mode Email Logging) explicitly specifies checking `FLASK_ENV`
|
||||||
|
- DEBUG can be True in testing environments where we don't want dev mode logging
|
||||||
|
- Attempting to send email (when configured) tests the real integration path
|
||||||
|
- Logging provides fallback when Resend not configured
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
|
||||||
|
def should_log_dev_mode_links() -> bool:
|
||||||
|
"""Check if development mode email logging is enabled.
|
||||||
|
|
||||||
|
CRITICAL: This must NEVER be True in production.
|
||||||
|
"""
|
||||||
|
env = os.environ.get('FLASK_ENV', '').lower()
|
||||||
|
return env == 'development'
|
||||||
|
|
||||||
|
def send_registration_confirmation(participant_id: int, token: str):
|
||||||
|
# Build magic link URL
|
||||||
|
magic_link_url = url_for('...')
|
||||||
|
|
||||||
|
# Development mode: log the link
|
||||||
|
if should_log_dev_mode_links():
|
||||||
|
logger.info(f"DEV MODE: Magic link generated for participant {participant.email}")
|
||||||
|
logger.info(f"DEV MODE: Full magic link URL: {magic_link_url}")
|
||||||
|
|
||||||
|
# Attempt to send email (even in dev mode, if configured)
|
||||||
|
try:
|
||||||
|
resend.Emails.send({...})
|
||||||
|
except Exception as e:
|
||||||
|
if should_log_dev_mode_links():
|
||||||
|
logger.info("Email service unavailable, but link logged for DEV MODE testing")
|
||||||
|
else:
|
||||||
|
raise # In production, we need to know if email failed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Magic Token Decisions
|
||||||
|
|
||||||
|
### 4.1 Token Cleanup Job
|
||||||
|
|
||||||
|
**Question**: Should token cleanup be implemented in Phase 2 with APScheduler, or use simpler on-demand cleanup?
|
||||||
|
|
||||||
|
**Answer**: **Use on-demand cleanup for Phase 2; defer APScheduler to Phase 5**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Simplicity first (core principle)
|
||||||
|
- Self-hosted deployment avoids unnecessary background processes
|
||||||
|
- Phase 2 design document doesn't require background jobs
|
||||||
|
- v0.1.0 data model mentions hourly cleanup, but Phase 5 (Background Jobs) is the proper place
|
||||||
|
- On-demand cleanup is sufficient for token table (tokens auto-expire, cleanup is optimization not requirement)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# Call during token validation
|
||||||
|
def validate_magic_token(token: str) -> TokenValidationResult:
|
||||||
|
# Before validation, opportunistically clean up expired tokens
|
||||||
|
cleanup_expired_tokens()
|
||||||
|
|
||||||
|
# Then validate token
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup_expired_tokens():
|
||||||
|
"""Remove expired tokens (called opportunistically)."""
|
||||||
|
MagicToken.query.filter(
|
||||||
|
MagicToken.expires_at < datetime.utcnow()
|
||||||
|
).delete()
|
||||||
|
db.session.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defer to Phase 5**: Proper scheduled cleanup with APScheduler or similar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Token Hash Algorithm
|
||||||
|
|
||||||
|
**Question**: Use hashlib.sha256() directly, or different library?
|
||||||
|
|
||||||
|
**Answer**: **Use hashlib.sha256() directly**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Standard library, no external dependencies
|
||||||
|
- Adequate security for token hashing (not password hashing)
|
||||||
|
- Phase 2 design explicitly specifies SHA-256: line 318 "SHA-256 hash of token"
|
||||||
|
- Existing project uses bcrypt for passwords (different use case)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def generate_magic_token(participant_id: int, exchange_id: int) -> str:
|
||||||
|
# Generate token
|
||||||
|
token = secrets.token_urlsafe(32) # 32 bytes
|
||||||
|
|
||||||
|
# Hash for storage
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Store hash, return original
|
||||||
|
magic_token = MagicToken(token_hash=token_hash, ...)
|
||||||
|
db.session.add(magic_token)
|
||||||
|
|
||||||
|
return token # Send in email
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Form Validation Decisions
|
||||||
|
|
||||||
|
### 5.1 Participant Registration Form
|
||||||
|
|
||||||
|
**Question**: Should reminder_enabled be BooleanField with default=True? How to handle checkbox initial state?
|
||||||
|
|
||||||
|
**Answer**: **Use BooleanField with default=True**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- WTForms BooleanField is designed for checkboxes
|
||||||
|
- Default True matches data model: "BOOLEAN NOT NULL, DEFAULT TRUE"
|
||||||
|
- Opt-out pattern (checked by default) is better UX for beneficial features
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# src/forms/participant.py
|
||||||
|
from wtforms import BooleanField, StringField, TextAreaField
|
||||||
|
from wtforms.validators import DataRequired, Email, Length
|
||||||
|
|
||||||
|
class ParticipantRegistrationForm(FlaskForm):
|
||||||
|
name = StringField('Name', validators=[DataRequired(), Length(max=255)])
|
||||||
|
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
gift_ideas = TextAreaField('Gift Ideas', validators=[Length(max=10000)])
|
||||||
|
reminder_enabled = BooleanField('Send me reminders', default=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Template**:
|
||||||
|
```html
|
||||||
|
<input type="checkbox" name="reminder_enabled" id="reminder_enabled"
|
||||||
|
{% if form.reminder_enabled.data %}checked{% endif %}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Gift Ideas Field Validation
|
||||||
|
|
||||||
|
**Question**: Max 10,000 chars - implement client-side counter, server-side truncation, or just server-side validation with error?
|
||||||
|
|
||||||
|
**Answer**: **Server-side validation with error + optional client-side counter (progressive enhancement)**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Security: Never trust client-side validation
|
||||||
|
- UX: Client-side counter is helpful but optional
|
||||||
|
- Existing form pattern: server-side validation with flash messages (see `/home/phil/Projects/sneaky-klaus/src/routes/admin.py`)
|
||||||
|
- No truncation: explicit error better than silent data loss
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# Server-side (required)
|
||||||
|
class ParticipantRegistrationForm(FlaskForm):
|
||||||
|
gift_ideas = TextAreaField('Gift Ideas', validators=[Length(max=10000)])
|
||||||
|
|
||||||
|
# Client-side (optional, progressive enhancement)
|
||||||
|
# In template:
|
||||||
|
<textarea name="gift_ideas" maxlength="10000"></textarea>
|
||||||
|
<small id="char-count">0 / 10,000 characters</small>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Optional character counter
|
||||||
|
document.querySelector('textarea[name="gift_ideas"]').addEventListener('input', (e) => {
|
||||||
|
document.getElementById('char-count').textContent =
|
||||||
|
`${e.target.value.length} / 10,000 characters`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Route Organization Decisions
|
||||||
|
|
||||||
|
### 6.1 Blueprint Structure
|
||||||
|
|
||||||
|
**Question**: Current admin login at /admin/login, design shows /auth/admin/login. Which structure?
|
||||||
|
|
||||||
|
**Answer**: **Option B - Keep admin auth in admin_bp, create participant routes in participant_bp**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Existing admin routes already in admin_bp with `/admin` prefix
|
||||||
|
- Breaking change to move admin auth would require updating tests, templates, and documentation
|
||||||
|
- Participant routes already have participant_bp: `/home/phil/Projects/sneaky-klaus/src/routes/participant.py`
|
||||||
|
- URL structure less important than consistency and minimal changes
|
||||||
|
|
||||||
|
**Blueprint Organization**:
|
||||||
|
- `admin_bp` (/admin/*): Admin login, logout, dashboard, exchanges
|
||||||
|
- `participant_bp` (no prefix): Participant registration, magic link auth, dashboard
|
||||||
|
- `setup_bp` (/setup): Initial setup (existing)
|
||||||
|
|
||||||
|
**Participant Routes**:
|
||||||
|
```python
|
||||||
|
# src/routes/participant.py
|
||||||
|
participant_bp = Blueprint('participant', __name__, url_prefix='')
|
||||||
|
|
||||||
|
@participant_bp.route('/exchange/<slug>/register', methods=['GET', 'POST'])
|
||||||
|
def register(slug):
|
||||||
|
"""Participant registration"""
|
||||||
|
|
||||||
|
@participant_bp.route('/exchange/<slug>/request-access', methods=['POST'])
|
||||||
|
def request_access(slug):
|
||||||
|
"""Request magic link for existing participant"""
|
||||||
|
|
||||||
|
@participant_bp.route('/auth/magic/<token>')
|
||||||
|
def magic_link_auth(token):
|
||||||
|
"""Validate magic link and create session"""
|
||||||
|
|
||||||
|
@participant_bp.route('/participant/dashboard')
|
||||||
|
@participant_required
|
||||||
|
def dashboard():
|
||||||
|
"""Participant dashboard"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do NOT create separate auth_bp** - would fragment routes unnecessarily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Public Routes
|
||||||
|
|
||||||
|
**Question**: Should we create public_bp, add to participant_bp, or register directly on app?
|
||||||
|
|
||||||
|
**Answer**: **Add to participant_bp (which has no prefix)**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Public routes (landing page, registration) are participant-facing
|
||||||
|
- participant_bp already has no prefix (empty string)
|
||||||
|
- No need for additional blueprint
|
||||||
|
- Simpler organization
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# src/routes/participant.py
|
||||||
|
participant_bp = Blueprint('participant', __name__, url_prefix='')
|
||||||
|
|
||||||
|
# Public routes (no auth required)
|
||||||
|
@participant_bp.route('/')
|
||||||
|
def landing():
|
||||||
|
"""Public landing page (optional)"""
|
||||||
|
|
||||||
|
@participant_bp.route('/exchange/<slug>/register', methods=['GET', 'POST'])
|
||||||
|
def register(slug):
|
||||||
|
"""Public registration page (no auth required)"""
|
||||||
|
|
||||||
|
# Protected routes (auth required)
|
||||||
|
@participant_bp.route('/participant/dashboard')
|
||||||
|
@participant_required
|
||||||
|
def dashboard():
|
||||||
|
"""Protected dashboard"""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Error Handling Decisions
|
||||||
|
|
||||||
|
### 7.1 Rate Limit Exceptions
|
||||||
|
|
||||||
|
**Question**: Define in new src/exceptions.py? Inherit from werkzeug or custom? Flash+render or exception-based?
|
||||||
|
|
||||||
|
**Answer**: **Create src/exceptions.py; inherit from werkzeug.exceptions.HTTPException; use exception-based handling**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Werkzeug exceptions integrate with Flask error handlers
|
||||||
|
- Existing codebase uses exception-based handling (see 404, 403, 500 handlers in `/home/phil/Projects/sneaky-klaus/src/app.py`)
|
||||||
|
- Centralized exception definitions improve maintainability
|
||||||
|
- Allows custom error handlers per exception type
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# src/exceptions.py
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
class RateLimitExceeded(HTTPException):
|
||||||
|
"""Raised when rate limit is exceeded."""
|
||||||
|
code = 429
|
||||||
|
description = "Too many requests. Please try again later."
|
||||||
|
|
||||||
|
class EmailAlreadyRegistered(HTTPException):
|
||||||
|
"""Raised when participant email already exists in exchange."""
|
||||||
|
code = 400
|
||||||
|
description = "This email is already registered for this exchange."
|
||||||
|
|
||||||
|
class RegistrationClosed(HTTPException):
|
||||||
|
"""Raised when registration is not open."""
|
||||||
|
code = 400
|
||||||
|
description = "Registration is not currently open for this exchange."
|
||||||
|
|
||||||
|
# src/app.py - error handlers
|
||||||
|
@app.errorhandler(RateLimitExceeded)
|
||||||
|
def handle_rate_limit(error):
|
||||||
|
flash(error.description, "error")
|
||||||
|
return redirect(request.referrer or '/'), 429
|
||||||
|
|
||||||
|
@app.errorhandler(EmailAlreadyRegistered)
|
||||||
|
def handle_email_exists(error):
|
||||||
|
flash(error.description, "error")
|
||||||
|
return render_template('participant/register.html', show_request_access=True), 400
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing Infrastructure Decisions
|
||||||
|
|
||||||
|
### 8.1 Email Testing
|
||||||
|
|
||||||
|
**Question**: Use mock email backend, TestEmailService, or configure test email backend in TestConfig?
|
||||||
|
|
||||||
|
**Answer**: **Use unittest.mock to patch EmailService in tests**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Existing test pattern: `/home/phil/Projects/sneaky-klaus/tests/conftest.py` uses fixtures
|
||||||
|
- Python unittest.mock is standard library
|
||||||
|
- Allows inspection of email calls (recipients, content, etc.)
|
||||||
|
- No test-specific email backend needed
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# tests/conftest.py
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_email_service():
|
||||||
|
"""Mock email service for testing."""
|
||||||
|
with patch('src.services.email.EmailService') as mock:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock.return_value = mock_instance
|
||||||
|
yield mock_instance
|
||||||
|
|
||||||
|
# tests/integration/test_participant_registration.py
|
||||||
|
def test_registration_sends_email(client, db, mock_email_service):
|
||||||
|
"""Test that registration sends confirmation email."""
|
||||||
|
response = client.post('/exchange/test-slug/register', data={
|
||||||
|
'name': 'Alice',
|
||||||
|
'email': 'alice@example.com',
|
||||||
|
'gift_ideas': 'Books',
|
||||||
|
'csrf_token': get_csrf_token()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Verify email was sent
|
||||||
|
mock_email_service.send_registration_confirmation.assert_called_once()
|
||||||
|
call_args = mock_email_service.send_registration_confirmation.call_args
|
||||||
|
assert call_args[0][0] > 0 # participant_id
|
||||||
|
assert len(call_args[0][1]) == 43 # token length
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Magic Link Testing
|
||||||
|
|
||||||
|
**Question**: Parse from mock email content, direct API to generate tokens, or both?
|
||||||
|
|
||||||
|
**Answer**: **Use direct API to generate tokens in tests; optionally verify email content in integration tests**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Unit tests should test token generation/validation directly
|
||||||
|
- Integration tests can verify full flow (including email)
|
||||||
|
- Parsing email content is brittle and slow
|
||||||
|
- Direct API access is faster and more reliable
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# Unit test - direct token generation
|
||||||
|
def test_magic_token_validation(db):
|
||||||
|
"""Test token validation."""
|
||||||
|
participant = create_test_participant()
|
||||||
|
|
||||||
|
# Generate token directly
|
||||||
|
from src.services.token import generate_magic_token
|
||||||
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
||||||
|
|
||||||
|
# Validate token
|
||||||
|
result = validate_magic_token(token)
|
||||||
|
assert result.valid is True
|
||||||
|
|
||||||
|
# Integration test - full flow with email
|
||||||
|
def test_registration_flow_with_magic_link(client, db, mock_email_service):
|
||||||
|
"""Test complete registration flow."""
|
||||||
|
# Register participant
|
||||||
|
response = client.post('/exchange/test-slug/register', data={...})
|
||||||
|
|
||||||
|
# Extract token from mock call (not from email content)
|
||||||
|
token = mock_email_service.send_registration_confirmation.call_args[0][1]
|
||||||
|
|
||||||
|
# Use magic link
|
||||||
|
response = client.get(f'/auth/magic/{token}')
|
||||||
|
assert response.status_code == 302
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Migration Decisions
|
||||||
|
|
||||||
|
### 9.1 Alembic Migrations
|
||||||
|
|
||||||
|
**Question**: Create Alembic migrations for Phase 2 models, or continue with db.create_all()?
|
||||||
|
|
||||||
|
**Answer**: **Use Alembic migrations for Phase 2 and all future schema changes**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Alembic infrastructure already exists in the codebase (alembic.ini, migrations/ directory)
|
||||||
|
- Initial migration already created for Admin and Exchange models
|
||||||
|
- Production-ready approach from the start avoids migration debt
|
||||||
|
- Enables safe, versioned schema evolution
|
||||||
|
- Supports rollback if needed
|
||||||
|
- Critical for any deployment with persistent data
|
||||||
|
|
||||||
|
**Implementation Approach**:
|
||||||
|
|
||||||
|
**For new Phase 2 models** (Participant, MagicToken):
|
||||||
|
```bash
|
||||||
|
# Generate migration for new models
|
||||||
|
uv run alembic revision --autogenerate -m "Add Participant and MagicToken models"
|
||||||
|
|
||||||
|
# Review the generated migration file
|
||||||
|
# Edit if needed to ensure correctness
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration Naming Conventions**:
|
||||||
|
- Use descriptive, imperative messages: "Add Participant model", "Add email index to Participant"
|
||||||
|
- Keep messages under 80 characters
|
||||||
|
- Use lowercase except for model/table names
|
||||||
|
|
||||||
|
**Testing Migrations**:
|
||||||
|
1. Test upgrade path: `alembic upgrade head`
|
||||||
|
2. Test downgrade path: `alembic downgrade -1` then `alembic upgrade head`
|
||||||
|
3. Verify database schema matches models
|
||||||
|
4. Run application tests after migration
|
||||||
|
|
||||||
|
**Handling Existing Databases**:
|
||||||
|
If a database was created with db.create_all() before migrations:
|
||||||
|
```bash
|
||||||
|
# Option 1: Stamp existing database with current migration
|
||||||
|
uv run alembic stamp head
|
||||||
|
|
||||||
|
# Option 2: Recreate database from migrations (dev only)
|
||||||
|
rm data/sneaky-klaus.db
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration Workflow**:
|
||||||
|
1. Create/modify SQLAlchemy models
|
||||||
|
2. Generate migration: `alembic revision --autogenerate -m "description"`
|
||||||
|
3. Review and test migration file
|
||||||
|
4. Apply migration: `alembic upgrade head`
|
||||||
|
5. Commit migration file with model changes
|
||||||
|
|
||||||
|
**Never use db.create_all() in production** - it does not handle schema changes safely.
|
||||||
|
|
||||||
|
### 9.2 Automatic Migrations for Deployments
|
||||||
|
|
||||||
|
**Question**: When self-hosted users pull a new container image with schema changes, how are migrations applied?
|
||||||
|
|
||||||
|
**Answer**: **Automatic migrations via container entrypoint script**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Self-hosted users should not need to manually run migration commands
|
||||||
|
- Container startup is the natural point to ensure schema is up-to-date
|
||||||
|
- Prevents application from starting with incompatible schema
|
||||||
|
- Provides clear error visibility if migrations fail
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
Create `entrypoint.sh` in the project root:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
if uv run alembic upgrade head; then
|
||||||
|
echo "Database migrations completed successfully"
|
||||||
|
else
|
||||||
|
echo "ERROR: Database migration failed!"
|
||||||
|
echo "Please check the logs above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting application server..."
|
||||||
|
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `Containerfile`:
|
||||||
|
```dockerfile
|
||||||
|
# ... existing content ...
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# ... existing content ...
|
||||||
|
|
||||||
|
# Change CMD to use entrypoint
|
||||||
|
CMD ["/app/entrypoint.sh"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- **First run**: Creates database and runs all migrations
|
||||||
|
- **Updates**: Applies only new migrations incrementally
|
||||||
|
- **Failures**: Container exits with error, preventing app startup with wrong schema
|
||||||
|
- **Logging**: Migration output visible in container logs
|
||||||
|
|
||||||
|
**Remove db.create_all() from Application**:
|
||||||
|
|
||||||
|
The `db.create_all()` call in `src/app.py` (line 77) must be removed because:
|
||||||
|
- It conflicts with migration-based schema management
|
||||||
|
- Cannot handle schema changes (only creates missing tables)
|
||||||
|
- ADR-0005 explicitly requires migrations for production
|
||||||
|
|
||||||
|
Update `src/app.py`:
|
||||||
|
```python
|
||||||
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
|
with app.app_context():
|
||||||
|
from src.models import Admin, Exchange, RateLimit # noqa: F401
|
||||||
|
# REMOVED: db.create_all() - schema managed by Alembic migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
**Development Workflow**:
|
||||||
|
Developers continue to run migrations manually for explicit control:
|
||||||
|
```bash
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
See ADR-0005 (Database Migrations) section "Automatic Migrations for Self-Hosted Deployments" for full rationale and alternative approaches considered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Order Decisions
|
||||||
|
|
||||||
|
### 10.1 Story Implementation Sequence
|
||||||
|
|
||||||
|
**Question**: Bottom-up (models → services → routes), story-by-story, or vertical slice?
|
||||||
|
|
||||||
|
**Answer**: **Vertical slice (one complete story at a time)**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- TDD-friendly: write test for story, implement minimum code to pass
|
||||||
|
- Delivers working features incrementally
|
||||||
|
- Easier to review and merge
|
||||||
|
- Matches project workflow (feature branches)
|
||||||
|
- Each story is independently testable
|
||||||
|
|
||||||
|
**Recommended Order**:
|
||||||
|
1. Story 3.1: Participant Registration (includes models, forms, routes, email service)
|
||||||
|
2. Story 3.2: Participant Authentication (magic links)
|
||||||
|
3. Story 3.3: Participant Dashboard
|
||||||
|
4. Story 3.4: Update Gift Ideas
|
||||||
|
5. Story 3.5: Withdraw from Exchange
|
||||||
|
|
||||||
|
**Within each story**:
|
||||||
|
1. Write integration test (story acceptance criteria)
|
||||||
|
2. Create models (if needed)
|
||||||
|
3. Create forms (if needed)
|
||||||
|
4. Create services (if needed)
|
||||||
|
5. Create routes
|
||||||
|
6. Create templates
|
||||||
|
7. Run tests, iterate until passing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10.2 Decorator Implementation
|
||||||
|
|
||||||
|
**Question**: Add to existing auth.py or create separate participant_decorators.py?
|
||||||
|
|
||||||
|
**Answer**: **Add to existing src/decorators/auth.py**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Single source of truth for authentication decorators
|
||||||
|
- admin_required already exists there: `/home/phil/Projects/sneaky-klaus/src/decorators/auth.py`
|
||||||
|
- Decorators are small, related functionality
|
||||||
|
- No need to split until file becomes unwieldy (>300 lines)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# src/decorators/auth.py
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Existing admin decorator"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def participant_required(f):
|
||||||
|
"""Decorator to require participant authentication."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*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('participant.landing'))
|
||||||
|
|
||||||
|
# Load participant
|
||||||
|
from src.models import Participant
|
||||||
|
participant = Participant.query.get(session['user_id'])
|
||||||
|
if not participant or participant.withdrawn_at is not None:
|
||||||
|
session.clear()
|
||||||
|
flash("Your session is invalid.", "error")
|
||||||
|
return redirect(url_for('participant.landing'))
|
||||||
|
|
||||||
|
g.participant = participant
|
||||||
|
g.exchange_id = session.get('exchange_id')
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def exchange_access_required(f):
|
||||||
|
"""Decorator to validate participant access to specific exchange.
|
||||||
|
Must be used after @participant_required.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
exchange_id = kwargs.get('exchange_id')
|
||||||
|
if not exchange_id:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
if g.participant.exchange_id != exchange_id:
|
||||||
|
flash("You don't have access to this exchange.", "error")
|
||||||
|
return redirect(url_for('participant.dashboard'))
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Development Mode Decisions
|
||||||
|
|
||||||
|
### 11.1 Dev Mode Testing
|
||||||
|
|
||||||
|
**Question**: Provide dev-only endpoint /dev/recent-links, or strictly logs-only? Show dev mode indicator in UI?
|
||||||
|
|
||||||
|
**Answer**: **Logs-only for Phase 2; no dev endpoints or UI indicators**
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ADR-0004 specifies logs-only approach
|
||||||
|
- Dev endpoints add complexity and security risk (even in dev mode)
|
||||||
|
- Logs are sufficient for QA workflow: `podman logs sneaky-klaus | grep "DEV MODE"`
|
||||||
|
- UI indicators add no value (developers know what environment they're in)
|
||||||
|
|
||||||
|
**Implementation**: Follow ADR-0004 exactly - log magic links to application logs when `FLASK_ENV=development`.
|
||||||
|
|
||||||
|
**Future enhancement** (if QA requests): Could add `/dev/magic-links` endpoint in Phase 8+, but require it to be explicitly enabled with additional env var (defense in depth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: Questions Requiring User Input
|
||||||
|
|
||||||
|
The following questions genuinely require user preference and should be escalated:
|
||||||
|
|
||||||
|
**NONE** - All questions have clear architectural answers based on existing patterns, best practices, or design decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist for Developer
|
||||||
|
|
||||||
|
Based on these decisions, the developer should:
|
||||||
|
|
||||||
|
1. **Database Models & Migrations**:
|
||||||
|
- [ ] Create Participant model with email normalization in setter
|
||||||
|
- [ ] Create MagicToken model with application-level validation in validate() method
|
||||||
|
- [ ] Generate Alembic migration for new models: `alembic revision --autogenerate -m "Add Participant and MagicToken models"`
|
||||||
|
- [ ] Review and test the generated migration
|
||||||
|
- [ ] Apply migration: `alembic upgrade head`
|
||||||
|
- [ ] Verify Flask-Session is properly configured (already done)
|
||||||
|
- [ ] Remove `db.create_all()` from `src/app.py` (replaced by migrations)
|
||||||
|
|
||||||
|
2. **Services**:
|
||||||
|
- [ ] Create src/services/email.py with EmailService class
|
||||||
|
- [ ] Create src/services/token.py with generate_magic_token() and validate_magic_token()
|
||||||
|
- [ ] Implement on-demand token cleanup in validate function
|
||||||
|
|
||||||
|
3. **Forms**:
|
||||||
|
- [ ] Create src/forms/participant.py with registration and request access forms
|
||||||
|
- [ ] Use BooleanField for reminder_enabled with default=True
|
||||||
|
- [ ] Use Length(max=10000) validator for gift_ideas
|
||||||
|
|
||||||
|
4. **Exceptions**:
|
||||||
|
- [ ] Create src/exceptions.py with RateLimitExceeded, EmailAlreadyRegistered, RegistrationClosed
|
||||||
|
- [ ] Register error handlers in src/app.py
|
||||||
|
|
||||||
|
5. **Routes**:
|
||||||
|
- [ ] Implement participant registration in participant_bp
|
||||||
|
- [ ] Implement magic link authentication in participant_bp
|
||||||
|
- [ ] Implement participant dashboard in participant_bp
|
||||||
|
- [ ] Keep admin auth in admin_bp (no changes)
|
||||||
|
|
||||||
|
6. **Decorators**:
|
||||||
|
- [ ] Add participant_required to src/decorators/auth.py
|
||||||
|
- [ ] Add exchange_access_required to src/decorators/auth.py
|
||||||
|
|
||||||
|
7. **Templates**:
|
||||||
|
- [ ] Create templates/emails/participant/ directory with email templates
|
||||||
|
- [ ] Create templates/participant/ directory with web templates
|
||||||
|
|
||||||
|
8. **Session Management**:
|
||||||
|
- [ ] Implement create_participant_session() using session.clear() pattern
|
||||||
|
- [ ] Use user_id + user_type + exchange_id session structure
|
||||||
|
- [ ] Set session.permanent = True (7-day sliding window)
|
||||||
|
|
||||||
|
9. **Development Mode**:
|
||||||
|
- [ ] Implement should_log_dev_mode_links() checking FLASK_ENV
|
||||||
|
- [ ] Log magic links in EmailService when dev mode enabled
|
||||||
|
|
||||||
|
10. **Testing**:
|
||||||
|
- [ ] Use unittest.mock to patch EmailService in tests
|
||||||
|
- [ ] Write vertical slice tests (one complete story at a time)
|
||||||
|
- [ ] Test token generation/validation directly (not via email parsing)
|
||||||
|
|
||||||
|
11. **Container & Deployment**:
|
||||||
|
- [ ] Create `entrypoint.sh` script to run migrations before starting app
|
||||||
|
- [ ] Make entrypoint script executable in Containerfile
|
||||||
|
- [ ] Update Containerfile CMD to use entrypoint script
|
||||||
|
- [ ] Test container startup with fresh database (first-run scenario)
|
||||||
|
- [ ] Test container update with existing database (migration scenario)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-0002: Authentication Strategy](/home/phil/Projects/sneaky-klaus/docs/decisions/0002-authentication-strategy.md)
|
||||||
|
- [ADR-0003: Participant Session Scoping](/home/phil/Projects/sneaky-klaus/docs/decisions/0003-participant-session-scoping.md)
|
||||||
|
- [ADR-0004: Development Mode Email Logging](/home/phil/Projects/sneaky-klaus/docs/decisions/0004-dev-mode-email-logging.md)
|
||||||
|
- [Phase 2 Design: Participant Authentication](/home/phil/Projects/sneaky-klaus/docs/designs/v0.2.0/components/participant-auth.md)
|
||||||
|
- [Data Model v0.2.0](/home/phil/Projects/sneaky-klaus/docs/designs/v0.2.0/data-model.md)
|
||||||
|
- [Project Overview](/home/phil/Projects/sneaky-klaus/docs/PROJECT_OVERVIEW.md)
|
||||||
14
entrypoint.sh
Executable file
14
entrypoint.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
if uv run alembic upgrade head; then
|
||||||
|
echo "Database migrations completed successfully"
|
||||||
|
else
|
||||||
|
echo "ERROR: Database migration failed!"
|
||||||
|
echo "Please check the logs above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting application server..."
|
||||||
|
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add Participant and MagicToken models
|
||||||
|
|
||||||
|
Revision ID: ccdbd24787d3
|
||||||
|
Revises: eeff6e1a89cd
|
||||||
|
Create Date: 2025-12-22 16:20:45.755948
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "ccdbd24787d3"
|
||||||
|
down_revision: str | Sequence[str] | None = "eeff6e1a89cd"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
11
src/app.py
11
src/app.py
@@ -72,9 +72,14 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
|
|
||||||
# Import models to ensure they're registered with SQLAlchemy
|
# Import models to ensure they're registered with SQLAlchemy
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from src.models import Admin, Exchange, RateLimit # noqa: F401
|
from src.models import ( # noqa: F401
|
||||||
|
Admin,
|
||||||
db.create_all()
|
Exchange,
|
||||||
|
MagicToken,
|
||||||
|
Participant,
|
||||||
|
RateLimit,
|
||||||
|
)
|
||||||
|
# Schema managed by Alembic migrations (applied via entrypoint script)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ This package contains all database models used by the application.
|
|||||||
|
|
||||||
from src.models.admin import Admin
|
from src.models.admin import Admin
|
||||||
from src.models.exchange import Exchange
|
from src.models.exchange import Exchange
|
||||||
|
from src.models.magic_token import MagicToken
|
||||||
|
from src.models.participant import Participant
|
||||||
from src.models.rate_limit import RateLimit
|
from src.models.rate_limit import RateLimit
|
||||||
|
|
||||||
__all__ = ["Admin", "Exchange", "RateLimit"]
|
__all__ = ["Admin", "Exchange", "MagicToken", "Participant", "RateLimit"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import string
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, DateTime, Integer, String, Text
|
from sqlalchemy import CheckConstraint, DateTime, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.app import db
|
from src.app import db
|
||||||
|
|
||||||
@@ -79,6 +79,11 @@ class Exchange(db.Model): # type: ignore[name-defined]
|
|||||||
DateTime, nullable=True, index=True
|
DateTime, nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
participants: Mapped[list["Participant"]] = relationship( # type: ignore # noqa: F821
|
||||||
|
"Participant", back_populates="exchange", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("max_participants >= 3", name="min_participants_check"),
|
CheckConstraint("max_participants >= 3", name="min_participants_check"),
|
||||||
)
|
)
|
||||||
|
|||||||
99
src/models/magic_token.py
Normal file
99
src/models/magic_token.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Magic token model for passwordless authentication.
|
||||||
|
|
||||||
|
Handles both participant magic links and admin password reset tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
|
|
||||||
|
from src.app import db
|
||||||
|
|
||||||
|
|
||||||
|
class MagicToken(db.Model): # type: ignore[name-defined]
|
||||||
|
"""Time-limited tokens for participant authentication and password reset.
|
||||||
|
|
||||||
|
Token types:
|
||||||
|
- magic_link: For participant passwordless authentication
|
||||||
|
- password_reset: For admin password reset
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- Original token is never stored (only SHA-256 hash)
|
||||||
|
- Tokens expire after 1 hour
|
||||||
|
- Single-use only (marked with used_at timestamp)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "magic_token"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
token_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
participant_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("participant.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
exchange_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("exchange.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
# Relationships (optional because password_reset tokens won't have these)
|
||||||
|
participant: Mapped["Participant | None"] = relationship("Participant") # type: ignore # noqa: F821
|
||||||
|
exchange: Mapped["Exchange | None"] = relationship("Exchange") # type: ignore # noqa: F821
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
db.Index("idx_magic_token_hash", "token_hash", unique=True),
|
||||||
|
db.Index("idx_magic_token_type_email", "token_type", "email"),
|
||||||
|
db.Index("idx_magic_token_expires_at", "expires_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates("token_type")
|
||||||
|
def validate_token_type(self, _key: str, value: str) -> str:
|
||||||
|
"""Validate token_type is one of the allowed values."""
|
||||||
|
allowed_types = ["magic_link", "password_reset"]
|
||||||
|
if value not in allowed_types:
|
||||||
|
raise ValueError(f"token_type must be one of {allowed_types}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Validate foreign key requirements based on token type.
|
||||||
|
|
||||||
|
- magic_link tokens require participant_id and exchange_id
|
||||||
|
- password_reset tokens must NOT have participant_id or exchange_id
|
||||||
|
"""
|
||||||
|
if self.token_type == "magic_link":
|
||||||
|
if not self.participant_id or not self.exchange_id:
|
||||||
|
raise ValueError(
|
||||||
|
"Magic link tokens require participant_id and exchange_id"
|
||||||
|
)
|
||||||
|
elif self.token_type == "password_reset" and (
|
||||||
|
self.participant_id or self.exchange_id
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Password reset tokens cannot have participant_id or exchange_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if token has expired."""
|
||||||
|
return bool(datetime.now(UTC) > self.expires_at)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_used(self) -> bool:
|
||||||
|
"""Check if token has already been used."""
|
||||||
|
return self.used_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""Check if token is valid (not expired and not used)."""
|
||||||
|
return not self.is_expired and not self.is_used
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of MagicToken."""
|
||||||
|
return f"<MagicToken {self.token_type} for {self.email}>"
|
||||||
64
src/models/participant.py
Normal file
64
src/models/participant.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Participant model for Sneaky Klaus.
|
||||||
|
|
||||||
|
Represents a person registered in a specific Secret Santa exchange.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.app import db
|
||||||
|
|
||||||
|
|
||||||
|
class Participant(db.Model): # type: ignore[name-defined]
|
||||||
|
"""A person registered in a specific exchange.
|
||||||
|
|
||||||
|
Each participant belongs to exactly one exchange. If the same email
|
||||||
|
registers for multiple exchanges, separate Participant records are created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "participant"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
exchange_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("exchange.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
gift_ideas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reminder_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
withdrawn_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
exchange: Mapped["Exchange"] = relationship( # type: ignore # noqa: F821
|
||||||
|
"Exchange", back_populates="participants"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
db.Index("idx_participant_exchange_id", "exchange_id"),
|
||||||
|
db.Index("idx_participant_email", "email"),
|
||||||
|
db.Index("idx_participant_exchange_email", "exchange_id", "email", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of Participant."""
|
||||||
|
return (
|
||||||
|
f"<Participant {self.name} ({self.email}) in Exchange {self.exchange_id}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_withdrawn(self) -> bool:
|
||||||
|
"""Check if participant has withdrawn from the exchange."""
|
||||||
|
return self.withdrawn_at is not None
|
||||||
Reference in New Issue
Block a user