chore: initial project setup

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

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

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

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

161
.claude/agents/architect.md Normal file
View File

@@ -0,0 +1,161 @@
# Architect Subagent
You are the **Software Architect** for Sneaky Klaus, a self-hosted Secret Santa organization application.
## Your Role
You design the technical architecture for Sneaky Klaus. You produce architectural decisions, system designs, data models, API specifications, and component diagrams. You do **not** write implementation code—that is handled by a separate developer subagent who will consume your designs.
## Core Technology Stack
These decisions are fixed:
| Component | Technology |
|-----------|------------|
| Backend Framework | Flask (Python) |
| Database | SQLite |
| Email Service | Resend |
| Deployment | Container-based (Docker) |
All other technical decisions are yours to make following established best practices, but you must always seek user approval before finalizing them.
## Your Expertise
You are an expert in:
- Building applications optimized for self-hosting via containers
- Flask application architecture and best practices
- SQLite optimization and appropriate use cases
- RESTful API design
- Secure authentication patterns (especially passwordless/magic link flows)
- Container-based deployment strategies
## Design Principles
1. **Simplicity**: Prefer simple, proven solutions over complex ones
2. **Self-host friendly**: Minimize external dependencies; easy single-container deployment
3. **AI-consumable output**: Your designs will be read and implemented by an AI developer subagent, not a human—structure your output for clarity and unambiguous interpretation
4. **Explicit over implicit**: State assumptions clearly; avoid ambiguity
5. **Security by default**: Design with security in mind from the start
## Output Locations
### Architecture Decision Records (ADRs)
Location: `docs/decisions/`
Filename format: `NNNN-short-title.md` (e.g., `0001-use-flask-sqlite.md`)
Use the following ADR template:
```markdown
# NNNN. Title
Date: YYYY-MM-DD
## Status
Proposed | Accepted | Deprecated | Superseded by [NNNN](link)
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?
### Positive
- Benefit 1
- Benefit 2
### Negative
- Drawback 1
- Drawback 2
### Neutral
- Trade-off 1
```
### Design Documents
Location: `docs/designs/vX.Y.Z/`
Version numbering follows Semantic Versioning 2.0.0:
- **MAJOR (X)**: Breaking changes to the design that would require significant rework of existing implementations
- **MINOR (Y)**: New features or components added in a backwards-compatible manner
- **PATCH (Z)**: Clarifications, corrections, or minor refinements to existing designs
Initial design work should begin at `v0.1.0`. Increment to `v1.0.0` when the design is considered stable and ready for production implementation.
Each version directory should contain:
| File | Purpose |
|------|---------|
| `overview.md` | High-level system architecture, component relationships, deployment model |
| `data-model.md` | Database schema, entity relationships, field definitions |
| `api-spec.md` | REST API endpoints, request/response formats, authentication |
| `components/` | Directory containing detailed component designs as needed |
Component documents (in `components/`) should be created per functional area when sufficient complexity warrants separation. Examples:
- `components/auth.md` - Admin and participant authentication flows
- `components/matching.md` - Matching algorithm design
- `components/notifications.md` - Email notification system
Use Mermaid diagrams liberally for:
- Entity relationship diagrams
- Sequence diagrams for flows
- Component/architecture diagrams
- State machine diagrams
### Preserving History
- Previous design versions are preserved as historical record
- When creating a new version, copy the previous version's directory and modify
- ADRs are never deleted; superseded decisions are marked as such with a link to the replacement
## Workflow
1. **Understand the requirement**: Read the relevant user stories from `docs/BACKLOG.md` and the product overview from `docs/PROJECT_OVERVIEW.md`
2. **Research if needed**: If you need to understand best practices or patterns, research before proposing
3. **Draft the design**: Create or update design documents and/or ADRs
4. **Seek approval**: Always present your designs and decisions to the user for approval before marking ADRs as "Accepted"
5. **Ask for clarification**: If requirements are ambiguous or you're unsure about a decision, ask the user rather than assuming
## Communication Style
- Be concise but thorough
- State your reasoning for decisions
- When presenting options, clearly indicate your recommendation and why
- Flag any concerns or risks proactively
- Remember your audience includes an AI developer subagent—be precise and unambiguous
## Key Reference Documents
Always consult these before designing:
- `docs/PROJECT_OVERVIEW.md` - Product vision, features, and scope
- `docs/BACKLOG.md` - User stories with acceptance criteria
- `docs/decisions/` - Existing architectural decisions
- `docs/designs/` - Current design documents
## What You Do NOT Do
- Write implementation code
- Make unilateral decisions without user approval
- Assume requirements—ask for clarification
- Design features outside the defined scope in PROJECT_OVERVIEW.md

309
.claude/agents/developer.md Normal file
View File

@@ -0,0 +1,309 @@
# Developer Subagent
You are the **Software Developer** for Sneaky Klaus, a self-hosted Secret Santa organization application.
## Your Role
You implement features based on designs provided by the architect. You write production code, tests, and ensure the application works correctly. You follow test-driven development practices and maintain high code quality standards.
## Core Technology Stack
| Component | Technology |
|-----------|------------|
| Backend Framework | Flask (Python) |
| Database | SQLite |
| Email Service | Resend |
| Deployment | Container-based (Docker) |
| Package Management | uv |
| Testing | pytest |
| Frontend | As specified by architect in design documents |
## Development Principles
1. **Test-Driven Development**: Write tests first, then implementation
2. **80% code coverage target**: Maintain minimum 80% test coverage
3. **No assumptions**: If something is unclear in the design, stop and ask the coordinator to consult the architect
4. **Stop on errors**: When you encounter failing tests, design inconsistencies, or blockers, stop and report to the coordinator immediately
5. **Clean code**: Follow Python best practices and PEP standards
6. **Mandatory docstrings**: All modules, classes, and functions must have docstrings
## Code Style & Standards
Follow these modern Python best practices:
### Formatting & Linting
- **Formatter**: Ruff (format)
- **Linter**: Ruff (lint)
- **Type checking**: Use type hints throughout; validate with mypy
- **Import sorting**: Handled by Ruff
### Ruff Configuration
Use these rules as a baseline in `pyproject.toml`:
```toml
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
```
### Docstring Style
Use Google-style docstrings:
```python
def function_name(param1: str, param2: int) -> bool:
"""Short description of function.
Longer description if needed, explaining the function's
purpose and behavior in more detail.
Args:
param1: Description of param1.
param2: Description of param2.
Returns:
Description of return value.
Raises:
ValueError: When param2 is negative.
"""
```
### Pre-commit Hooks
Set up pre-commit with the following `.pre-commit-config.yaml`:
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies: []
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
```
## Environment & Dependency Management
Use `uv` for all environment and dependency management:
```bash
# Create virtual environment
uv venv
# Add dependencies
uv add flask
uv add --dev pytest pytest-cov ruff mypy pre-commit
# Sync environment
uv sync
# Run commands in environment
uv run pytest
uv run flask run
```
Maintain dependencies in `pyproject.toml` using uv's native format.
## Testing Standards
### Test-Driven Development Workflow
1. Read the user story and acceptance criteria
2. Write failing tests that verify the acceptance criteria
3. Implement the minimum code to make tests pass
4. Refactor while keeping tests green
5. Verify coverage meets 80% target
### Test Organization
```
tests/
├── conftest.py # Shared fixtures
├── unit/ # Unit tests
│ ├── test_models.py
│ ├── test_services.py
│ └── ...
├── integration/ # Integration tests
│ ├── test_api.py
│ └── ...
└── fixtures/ # Test data
```
### pytest Configuration
In `pyproject.toml`:
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = [
"--cov=src",
"--cov-report=term-missing",
"--cov-fail-under=80",
]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
]
```
## Git Workflow
Follow trunk-based development with short-lived feature branches.
### Branch Naming
| Type | Pattern | Example |
|------|---------|---------|
| Feature | `feature/<story-id>-short-description` | `feature/2.1-create-exchange` |
| Bug fix | `fix/<issue>-short-description` | `fix/matching-self-assignment` |
| Chore | `chore/short-description` | `chore/update-dependencies` |
### Commit Practices
- **One commit per user story** when the story is complete
- You may commit after each logical unit of work during development
- Write clear, descriptive commit messages
- Reference story IDs in commit messages when applicable
### Commit Message Format
```
<type>: <short description>
<optional body explaining what and why>
Story: <story-id>
```
Types: `feat`, `fix`, `test`, `refactor`, `chore`, `docs`
Example:
```
feat: implement exchange creation
Add Exchange model, API endpoint, and form validation
for creating new gift exchanges.
Story: 2.1
```
### Merge to Main
- Ensure all tests pass before merging
- Ensure coverage threshold is met
- Merge feature branches to `main` when story is complete
- Delete feature branch after merge
## Key Reference Documents
Always consult these before implementing:
1. **Design documents** (primary source): `docs/designs/vX.Y.Z/`
- `overview.md` - System architecture
- `data-model.md` - Database schema
- `api-spec.md` - API specifications
- `components/*.md` - Detailed component designs
2. **User stories**: `docs/BACKLOG.md`
- Acceptance criteria define what "done" means
3. **Architectural decisions**: `docs/decisions/`
- Understand the reasoning behind design choices
4. **Product overview**: `docs/PROJECT_OVERVIEW.md`
- Understand the product context
## Workflow
1. **Receive assignment**: Get a user story or task from the coordinator
2. **Read the design**: Study the relevant design documents thoroughly
3. **Clarify if needed**: If the design is ambiguous or incomplete, stop and ask the coordinator to consult the architect—do not assume
4. **Create feature branch**: Branch from `main` with appropriate naming
5. **Write tests first**: Create tests based on acceptance criteria
6. **Implement**: Write code to make tests pass
7. **Verify quality**:
- All tests pass
- Coverage ≥ 80%
- Linting passes
- Type checking passes
8. **Commit and merge**: Commit with descriptive message, merge to `main`
9. **Report completion**: Inform coordinator the story is complete
## Error Handling Protocol
When you encounter any of the following, **stop immediately** and report to the coordinator:
- Failing tests you cannot resolve
- Design inconsistencies or contradictions
- Missing information in design documents
- Unclear acceptance criteria
- Dependency issues or conflicts
- Security concerns
- Anything that blocks progress
Do not attempt workarounds or assumptions. Report the issue clearly with:
- What you were trying to do
- What went wrong
- What information or decision you need
## What You Do NOT Do
- Make architectural decisions—defer to the architect via coordinator
- Assume requirements or fill in design gaps
- Continue past blockers or errors
- Skip tests or compromise on coverage
- Write code without corresponding design documents
- Modify design documents (request changes through coordinator)

65
.gitignore vendored Normal file
View File

@@ -0,0 +1,65 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Flask
instance/
.webassets-cache
# Database
data/
*.db
*.sqlite
*.sqlite3
# Environment variables
.env
.env.local
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# Alembic
# Keep migrations directory but ignore __pycache__
migrations/__pycache__/

21
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies: []
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

55
CLAUDE.md Normal file
View File

@@ -0,0 +1,55 @@
# Sneaky Klaus
A self-hosted Secret Santa organization application.
## Project Documentation
- `docs/PROJECT_OVERVIEW.md` - Product vision and scope
- `docs/BACKLOG.md` - User stories with acceptance criteria
- `docs/decisions/` - Architecture Decision Records
- `docs/designs/vX.Y.Z/` - Design documents (versioned)
## Workflow
This project uses a two-agent workflow coordinated by the main agent:
1. **Architect** (`.claude/agents/architect.md`) - Designs systems, produces ADRs and design docs
2. **Developer** (`.claude/agents/developer.md`) - Implements features from designs using TDD
Flow: User Story → Architect designs → User approves → Developer implements
The main agent coordinates between subagents. If the developer needs architectural clarification, route through the main agent to the architect.
## Tech Stack
| Component | Technology |
|-----------|------------|
| Backend | Flask (Python) |
| Database | SQLite |
| Email | Resend |
| Deployment | Docker |
| Package Manager | uv |
| Testing | pytest (80% coverage target) |
| Linting/Formatting | Ruff |
| Type Checking | mypy |
## Commands
```bash
uv sync # Install dependencies
uv run flask run # Run development server
uv run pytest # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
uv run mypy src # Type check
```
## Git Workflow
Trunk-based development with short-lived branches:
- `feature/<story-id>-description` - New features
- `fix/<description>` - Bug fixes
- `chore/<description>` - Maintenance
Merge to `main` when complete. Delete branch after merge.

147
alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url is configured dynamically in migrations/env.py from Flask config
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

722
docs/BACKLOG.md Normal file
View File

@@ -0,0 +1,722 @@
# Sneaky Klaus - Product Backlog
## Overview
This backlog contains user stories for Sneaky Klaus, organized by epic. Stories follow the format:
> **As a** [role], **I want** [capability], **so that** [benefit].
Each story includes acceptance criteria to define "done."
---
## Epic 1: Admin Account Management
### 1.1 Initial Admin Setup
**As a** first-time user, **I want** to create an admin account during initial setup, **so that** I can manage gift exchanges.
**Acceptance Criteria:**
- Setup screen appears on first application access
- Requires email address and password
- Password must meet minimum security requirements
- After setup, user is logged in as admin
- Setup screen is not accessible after initial admin creation
---
### 1.2 Admin Login
**As an** admin, **I want** to log in with my email and password, **so that** I can access the admin dashboard.
**Acceptance Criteria:**
- Login form accepts email and password
- Invalid credentials show appropriate error message
- Successful login redirects to admin dashboard
- Session persists across browser refreshes
---
### 1.3 Admin Password Recovery
**As an** admin, **I want** to reset my password via email, **so that** I can regain access if I forget my password.
**Acceptance Criteria:**
- "Forgot password" link on login page
- Entering admin email sends reset link
- Reset link expires after reasonable time period
- Reset link can only be used once
- New password must meet security requirements
- Confirmation shown after successful reset
---
### 1.4 Admin Logout
**As an** admin, **I want** to log out of my account, **so that** I can secure my session.
**Acceptance Criteria:**
- Logout option available from admin interface
- Logout clears session
- Redirects to login page after logout
---
## Epic 2: Exchange Management
### 2.1 Create Exchange
**As an** admin, **I want** to create a new gift exchange, **so that** I can organize a Secret Santa event.
**Acceptance Criteria:**
- Form to create exchange with fields:
- Name (required)
- Description (optional)
- Gift budget/price range (required)
- Maximum participants (required, minimum 3)
- Registration close date (required)
- Exchange date (required)
- Timezone (required)
- Exchange created in "Draft" state
- Exchange appears in admin dashboard after creation
---
### 2.2 View Exchange List
**As an** admin, **I want** to see all my gift exchanges, **so that** I can manage them.
**Acceptance Criteria:**
- Dashboard shows list of all exchanges
- Each exchange displays: name, state, participant count, exchange date
- Exchanges sorted by exchange date (upcoming first)
- Visual indicator for exchange state
---
### 2.3 View Exchange Details
**As an** admin, **I want** to view full details of an exchange, **so that** I can see its current status and participants.
**Acceptance Criteria:**
- Clicking an exchange opens detail view
- Shows all exchange information
- Shows list of registered participants
- Shows current state
- Shows registration link (when applicable)
---
### 2.4 Edit Exchange
**As an** admin, **I want** to edit exchange details, **so that** I can correct mistakes or update information.
**Acceptance Criteria:**
- All fields editable when exchange is in Draft, Registration Open, or Registration Closed state
- Cannot edit after matching has occurred
- Changes saved immediately
- Confirmation message after save
---
### 2.5 Delete Exchange
**As an** admin, **I want** to delete an exchange, **so that** I can remove cancelled or test exchanges.
**Acceptance Criteria:**
- Delete option available for any exchange
- Confirmation required before deletion
- Deletion removes all associated data (participants, matches)
- Exchange no longer appears in dashboard
---
### 2.6 Generate Registration Link
**As an** admin, **I want** a shareable registration link for each exchange, **so that** I can invite participants.
**Acceptance Criteria:**
- Unique link generated for each exchange
- Link is copyable to clipboard
- Link is displayed when exchange is in appropriate state
- Link leads to registration page for that specific exchange
---
## Epic 3: Exchange State Management
### 3.1 Open Registration
**As an** admin, **I want** to open registration for an exchange, **so that** participants can join.
**Acceptance Criteria:**
- "Open Registration" action available from Draft state
- Exchange state changes to "Registration Open"
- Registration link becomes active
- Participants can now access registration form
---
### 3.2 Close Registration
**As an** admin, **I want** to close registration, **so that** I can prepare for matching.
**Acceptance Criteria:**
- "Close Registration" action available from Registration Open state
- Exchange state changes to "Registration Closed"
- Registration link no longer accepts new registrations
- Existing participants can still access their profile via magic link
---
### 3.3 Reopen Registration (Pre-Matching)
**As an** admin, **I want** to reopen registration before matching, **so that** I can allow late additions.
**Acceptance Criteria:**
- "Reopen Registration" action available from Registration Closed state
- Exchange state changes back to "Registration Open"
- New participants can register
- Existing participants retained
---
### 3.4 Reopen Registration (Post-Matching)
**As an** admin, **I want** to reopen registration after matching, **so that** I can add participants who were missed.
**Acceptance Criteria:**
- "Reopen Registration" action available from Matched state
- Warning displayed that existing matches will be cleared
- Confirmation required
- All matches cleared upon confirmation
- Exchange state changes to "Registration Open"
---
### 3.5 Mark Exchange Complete
**As an** admin, **I want** to mark an exchange as complete, **so that** it's clear the event has concluded.
**Acceptance Criteria:**
- "Mark Complete" action available from Matched state
- Exchange state changes to "Completed"
- Exchange moves to completed section in dashboard
- 30-day retention countdown begins
---
### 3.6 Automatic Exchange Completion
**As a** system, **I want** to automatically mark exchanges as complete after the exchange date, **so that** data retention policies are enforced.
**Acceptance Criteria:**
- Exchanges in Matched state auto-transition to Completed after exchange date passes
- 30-day retention countdown begins automatically
- Admin can still access completed exchange data during retention period
---
## Epic 4: Participant Registration
### 4.1 Access Registration Page
**As a** potential participant, **I want** to access the registration page via shared link, **so that** I can join an exchange.
**Acceptance Criteria:**
- Registration link opens registration page
- Page displays exchange name, description, budget, and exchange date
- Shows registration form if registration is open
- Shows appropriate message if registration is closed
- Shows appropriate message if exchange doesn't exist
---
### 4.2 New Participant Registration
**As a** potential participant, **I want** to register for an exchange, **so that** I can participate in Secret Santa.
**Acceptance Criteria:**
- Registration form with fields:
- Name (required)
- Email (required, valid email format)
- Gift ideas (optional, multi-line text)
- Opt-in for reminder emails (checkbox)
- Email uniqueness checked within exchange
- Confirmation message after successful registration
- Confirmation email sent to participant
---
### 4.3 Returning Participant Detection
**As a** returning participant, **I want** to be recognized when I click the registration link again, **so that** I don't accidentally create duplicate registrations.
**Acceptance Criteria:**
- After clicking registration link, option to enter email to check existing registration
- If email found, prompt to send magic link
- If email not found, show registration form
- Clear messaging about what's happening
---
### 4.4 Admin Self-Registration
**As an** admin, **I want** to register myself as a participant in an exchange I created, **so that** I can participate in my own Secret Santa.
**Acceptance Criteria:**
- Option for admin to add themselves as participant
- Uses admin's email by default (editable)
- Admin registered same as any other participant
- Admin clearly marked in participant list (for admin view only)
---
### 4.5 View Participant List (Pre-Matching)
**As a** registered participant, **I want** to see who else has registered, **so that** I know who's participating.
**Acceptance Criteria:**
- Participant list visible after logging in via magic link
- Shows display names only
- Does not show email addresses
- Does not indicate any match information
- Updates as new participants register
---
## Epic 5: Participant Authentication
### 5.1 Magic Link Request
**As a** registered participant, **I want** to request a login link via email, **so that** I can access my exchange information.
**Acceptance Criteria:**
- "Access my registration" or similar option on registration page
- Enter email to request magic link
- If email registered, magic link sent
- If email not registered, appropriate message shown (without revealing registration status for security)
- Magic link expires after reasonable time period
---
### 5.2 Magic Link Login
**As a** participant, **I want** to log in by clicking the magic link, **so that** I can access my information without a password.
**Acceptance Criteria:**
- Clicking valid magic link logs participant in
- Redirects to participant dashboard
- Magic link can only be used once
- Expired link shows appropriate message with option to request new link
---
### 5.3 Participant Session
**As a** logged-in participant, **I want** my session to persist, **so that** I don't have to log in repeatedly.
**Acceptance Criteria:**
- Session persists across browser refreshes
- Session expires after reasonable inactivity period
- Participant can manually log out
---
## Epic 6: Participant Self-Management
### 6.1 Update Profile
**As a** registered participant, **I want** to update my gift ideas, **so that** my Secret Santa has current information.
**Acceptance Criteria:**
- Edit option available when logged in
- Can update name and gift ideas
- Cannot change email (request admin help)
- Only available before matching occurs
- Confirmation after save
---
### 6.2 Withdraw from Exchange
**As a** registered participant, **I want** to withdraw from an exchange, **so that** I can opt out if my circumstances change.
**Acceptance Criteria:**
- "Withdraw" option available before registration closes
- Confirmation required
- Participant removed from exchange
- Confirmation email sent
- Admin notified (if notifications enabled)
---
### 6.3 Update Reminder Preferences
**As a** participant, **I want** to change my reminder email preferences, **so that** I can control notifications.
**Acceptance Criteria:**
- Option to enable/disable reminder emails
- Available before exchange completes
- Changes take effect immediately
---
## Epic 7: Exclusion Rules
### 7.1 View Participants for Exclusions
**As an** admin, **I want** to see all participants when configuring exclusions, **so that** I can set up appropriate rules.
**Acceptance Criteria:**
- List of all participants displayed
- Available after registration is closed
- Shows participant names clearly
---
### 7.2 Add Exclusion Rule
**As an** admin, **I want** to add exclusion rules, **so that** certain participants won't be matched together.
**Acceptance Criteria:**
- Interface to select two participants who shouldn't be matched
- Exclusion is bidirectional (A won't give to B, B won't give to A)
- Multiple exclusions can be added
- Visual confirmation of added exclusion
---
### 7.3 Remove Exclusion Rule
**As an** admin, **I want** to remove exclusion rules, **so that** I can correct mistakes.
**Acceptance Criteria:**
- List of current exclusions displayed
- Option to remove each exclusion
- Removal is immediate
- Confirmation shown
---
### 7.4 Exclusion Validation
**As an** admin, **I want** to know if my exclusions make matching impossible, **so that** I can adjust before attempting to match.
**Acceptance Criteria:**
- Warning displayed if exclusions may prevent valid matching
- Explanation of which exclusions are problematic
- Matching blocked if mathematically impossible
---
## Epic 8: Matching
### 8.1 Trigger Matching
**As an** admin, **I want** to trigger the matching process, **so that** participants are assigned their recipients.
**Acceptance Criteria:**
- "Match Participants" action available from Registration Closed state
- Minimum 3 participants required
- Matching algorithm runs respecting all exclusions
- Exchange state changes to "Matched" on success
- Admin notified of success
---
### 8.2 Matching Algorithm
**As a** system, **I want** to randomly match participants following Secret Santa best practices, **so that** the exchange is fair and secret.
**Acceptance Criteria:**
- No participant is matched to themselves
- All exclusion rules are honored
- Each participant gives exactly one gift
- Each participant receives exactly one gift
- Single cycle preferred (A→B→C→A) when possible
- Randomization ensures unpredictability
---
### 8.3 Matching Failure Handling
**As an** admin, **I want** to be informed if matching fails, **so that** I can resolve the issue.
**Acceptance Criteria:**
- Clear error message if matching impossible
- Explanation of why (e.g., "Too many exclusions")
- Suggestions for resolution
- Exchange remains in Registration Closed state
---
### 8.4 View Matches (Admin)
**As an** admin, **I want** to view all matches, **so that** I can troubleshoot issues or handle disputes.
**Acceptance Criteria:**
- Admin can see full match list
- Shows who is giving to whom
- Only visible to admin
- Available after matching
---
### 8.5 Re-Match Participants
**As an** admin, **I want** to trigger a fresh re-match, **so that** I can resolve issues with current assignments.
**Acceptance Criteria:**
- "Re-match" option available in Matched state
- Confirmation required (warns all matches will be cleared)
- New random matching performed
- New notification emails sent to all participants
- Old matches completely replaced
---
### 8.6 Manual Match Override
**As an** admin, **I want** to manually assign or change specific matches, **so that** I can handle special circumstances.
**Acceptance Criteria:**
- Interface to change individual assignments
- Validation prevents invalid states (no self-matching, everyone has exactly one recipient)
- Changes saved immediately
- Affected participants receive updated notification
---
## Epic 9: Participant Removal (Post-Registration Close)
### 9.1 Remove Participant (Admin)
**As an** admin, **I want** to remove a participant at any stage, **so that** I can handle dropouts.
**Acceptance Criteria:**
- Remove option available for any participant
- Confirmation required
- If before matching: participant simply removed
- If after matching: triggers re-match requirement
- Removed participant notified via email
---
### 9.2 Handle Post-Match Participant Removal
**As a** system, **I want** to handle participant removal after matching, **so that** the exchange can continue.
**Acceptance Criteria:**
- When participant removed after matching, admin notified
- Option to auto-rematch (system re-runs matching)
- Option to manually reassign affected matches only
- All affected participants notified of changes
---
## Epic 10: Notifications
### 10.1 Registration Confirmation Email
**As a** newly registered participant, **I want** to receive a confirmation email, **so that** I know my registration was successful.
**Acceptance Criteria:**
- Email sent immediately after registration
- Includes exchange name and date
- Includes magic link to access registration
- Includes confirmation of provided details
---
### 10.2 Match Notification Email
**As a** participant, **I want** to receive an email with my match assignment, **so that** I know who to buy for.
**Acceptance Criteria:**
- Email sent immediately after matching
- Includes recipient's name
- Includes recipient's gift ideas
- Includes gift budget
- Includes exchange date
- Includes magic link to view in app
---
### 10.3 Reminder Emails
**As a** participant who opted in, **I want** to receive reminder emails before the exchange, **so that** I don't forget to buy a gift.
**Acceptance Criteria:**
- Reminders sent only to opted-in participants
- Reminder schedule configurable by admin
- Includes recipient info and exchange date
- Includes magic link to view full details
---
### 10.4 Admin Notification: New Registration
**As an** admin, **I want** to be notified of new registrations, **so that** I can track participation.
**Acceptance Criteria:**
- Email sent when new participant registers
- Only if admin has enabled this notification
- Includes participant name and exchange name
---
### 10.5 Admin Notification: Participant Withdrawal
**As an** admin, **I want** to be notified when a participant withdraws, **so that** I can follow up if needed.
**Acceptance Criteria:**
- Email sent when participant withdraws
- Only if admin has enabled this notification
- Includes participant name and exchange name
---
### 10.6 Admin Notification: Matching Complete
**As an** admin, **I want** to be notified when matching completes, **so that** I know the exchange is ready.
**Acceptance Criteria:**
- Email sent after successful matching
- Only if admin has enabled this notification
- Includes exchange name and participant count
---
### 10.7 Configure Admin Notifications
**As an** admin, **I want** to configure which notifications I receive, **so that** I'm not overwhelmed with emails.
**Acceptance Criteria:**
- Settings page for notification preferences
- Toggle for each notification type
- Can set globally or per-exchange
- Changes take effect immediately
---
## Epic 11: Participant Dashboard
### 11.1 View My Assignment
**As a** matched participant, **I want** to view who I'm buying for, **so that** I can purchase an appropriate gift.
**Acceptance Criteria:**
- Dashboard shows assigned recipient's name
- Shows recipient's gift ideas
- Shows gift budget
- Shows exchange date
- Available after matching occurs
---
### 11.2 View Exchange Information
**As a** participant, **I want** to view exchange details, **so that** I have all the information I need.
**Acceptance Criteria:**
- Shows exchange name and description
- Shows gift budget
- Shows exchange date and timezone
- Shows registration status/state
---
### 11.3 View Participant List (Post-Matching)
**As a** participant, **I want** to see who's in the exchange, **so that** I know the group.
**Acceptance Criteria:**
- List of all participant names
- Does not reveal who is matched to whom
- Does not reveal who my Secret Santa is
---
## Epic 12: Data Retention
### 12.1 Automatic Data Purge
**As a** system, **I want** to automatically purge exchange data after 30 days, **so that** participant data isn't retained indefinitely.
**Acceptance Criteria:**
- Completed exchanges tracked with completion date
- 30 days after completion, all data purged automatically
- Purge includes: participants, matches, gift ideas, emails
- Admin notified before purge (e.g., 7 days warning)
---
### 12.2 View Data Retention Status
**As an** admin, **I want** to see when exchange data will be purged, **so that** I can export if needed.
**Acceptance Criteria:**
- Completed exchanges show days until purge
- Clear indication of purge date
---
## Epic 13: Responsive Design
### 13.1 Mobile-Friendly Participant Experience
**As a** participant on a mobile device, **I want** the interface to work well on my phone, **so that** I can easily register and view my assignment.
**Acceptance Criteria:**
- Registration form usable on mobile
- Dashboard readable on mobile
- Touch targets appropriately sized
- No horizontal scrolling required
---
### 13.2 Mobile-Friendly Admin Experience
**As an** admin on a mobile device, **I want** to manage exchanges from my phone, **so that** I can administer on the go.
**Acceptance Criteria:**
- Admin dashboard usable on mobile
- Exchange creation/editing works on mobile
- Participant management works on mobile
- All critical functions accessible
---
## Epic 14: Reminder Configuration
### 14.1 Configure Reminder Schedule
**As an** admin, **I want** to configure when reminder emails are sent, **so that** I can customize the experience.
**Acceptance Criteria:**
- Settings to define reminder intervals (e.g., 7 days, 3 days, 1 day before)
- Can be set per-exchange or as default
- Only affects participants who opted in
---
## Story Status Key
| Status | Description |
|--------|-------------|
| 📋 Backlog | Not yet started |
| 🔄 In Progress | Currently being worked on |
| ✅ Done | Completed and tested |
| 🚫 Blocked | Cannot proceed due to dependency |
---
## Notes
- Stories are roughly ordered by dependency and priority within each epic
- Epics 1-4 represent core MVP functionality
- Epics 5-6 complete the participant experience
- Epics 7-9 handle the matching complexity
- Epics 10-14 are supporting features that enhance the experience
- Story estimates and sprint assignments to be added during planning sessions

231
docs/PROJECT_OVERVIEW.md Normal file
View File

@@ -0,0 +1,231 @@
# Sneaky Klaus - Product Overview
## Vision
Sneaky Klaus is a simple, self-hosted web application for organizing Secret Santa gift exchanges. It removes the friction of coordinating gift exchanges by handling participant registration, random matching, and email notifications—all while maintaining the secrecy that makes Secret Santa fun.
The application is designed for individuals, families, or small organizations who want full control over their data without relying on third-party services.
---
## Core Principles
- **Simplicity first**: Participants should be able to join an exchange in under a minute
- **No participant accounts**: Participants authenticate via email magic links, not passwords
- **Privacy by design**: Only the gifter knows who they're buying for; the system never reveals matches to anyone else
- **Self-hosted**: All data stays on infrastructure you control
- **Mobile-friendly**: Works seamlessly on phones, tablets, and desktops
---
## User Roles
### Administrator
- Single admin account for the entire installation
- Creates and manages all gift exchanges
- May optionally participate in exchanges they create
- Authenticates with email and password
- Can recover password via email reset link
### Participant
- Joins exchanges via shareable registration links
- No password required—authentication via email magic link
- Can participate in multiple exchanges with the same email address
- Can view their assigned recipient and the participant list (but not other matches)
---
## Gift Exchange Lifecycle
An exchange progresses through the following states:
```
Draft → Registration Open → Registration Closed → Matched → Completed
```
### State Descriptions
1. **Draft**: Exchange is created but not yet accepting registrations
2. **Registration Open**: Participants can join via the shareable link
3. **Registration Closed**: No new registrations; admin configures exclusions and triggers matching
4. **Matched**: Participants have been assigned recipients; notifications sent
5. **Completed**: Exchange date has passed; data retained for 30 days then purged
### State Transitions
- Forward progression through states is the normal flow
- Backward movement is permitted:
- Reopening registration after closing (before matching) adds new participants
- Reopening registration after matching clears all existing matches and requires fresh re-matching
- Admin controls all state transitions manually (except automatic completion based on date)
---
## Features
### Exchange Management (Admin)
#### Creating an Exchange
- Name/title for the exchange
- Description (optional)
- Suggested gift budget/price range
- Maximum participant limit (configurable per exchange; minimum is always 3)
- Expected close date (when registration should end)
- Exchange date (when gifts should be exchanged)
- Timezone for all dates
#### Editing an Exchange
- All fields are editable until matching has occurred
- After matching, exchange details are locked
#### Registration Link
- Each exchange has a unique, shareable registration link
- Link can be shared via any channel (email, messaging apps, etc.)
- Link remains active while registration is open
#### Closing Registration & Matching
- Admin manually closes registration when ready
- Before matching, admin can define exclusion rules (e.g., "Person A should not be matched with Person B")
- Admin triggers the matching process
- System randomly assigns each participant a recipient following Secret Santa best practices:
- No self-matching
- Exclusion rules are honored
- Single cycle preferred (A→B→C→A) to ensure everyone gives and receives exactly once
#### Handling Issues
- **Re-matching**: If needed, admin can trigger a complete re-match
- **Manual assignment**: Admin can manually override or assign specific matches
- **Participant removal**: Admin can remove participants at any stage
### Participant Registration
#### Joining an Exchange
- Participant clicks the shareable link
- System checks if their email is already registered for this exchange
- If yes: prompts to send magic link to access their registration
- If no: shows registration form
#### Registration Form
- Name (display name for other participants)
- Email address
- Gift ideas (single multi-line text field for wishlist/preferences)
#### Self-Management
- Participants can withdraw from an exchange before registration closes
- Participants can update their gift ideas before registration closes
- After matching, profile information is locked
### Matching & Notifications
#### Matching Algorithm
- Implements proper Secret Santa matching:
- Creates a single cycle where possible (ensures no small isolated groups)
- Honors all exclusion rules
- Guarantees everyone gives exactly one gift and receives exactly one gift
- If matching is impossible due to exclusion rules, admin is notified with explanation
#### Participant Notifications
- Upon matching, each participant receives an email containing:
- Name of their assigned recipient
- Recipient's gift ideas
- Gift budget for the exchange
- Exchange date
- Email includes a magic link to view this information in the app
### Participant Experience (Post-Matching)
#### Viewing Assignment
- Participants can log in via magic link at any time
- Dashboard shows:
- Who they are buying for
- That person's gift ideas
- Gift budget
- Exchange date
- List of all participants in the exchange (names only, not matches)
### Reminders
- Automatic reminder emails as exchange date approaches
- Reminders are opt-in only (participant chooses during registration)
- Suggested reminder schedule: configurable by admin (e.g., 7 days, 3 days, 1 day before)
### Admin Notifications
- Admin can opt-in to receive notifications for:
- New participant registration
- Participant withdrawal
- Registration closed confirmation
- Matching complete
- Matching failures/issues
- Notification preferences are configurable per exchange or globally
---
## Data Management
### Retention Policy
- Exchange data (participants, matches, gift ideas) is retained for 30 days after the exchange date
- After 30 days, all exchange data is automatically purged
- Admin can manually delete exchanges at any time
### Privacy Considerations
- Participant email addresses are only visible to the admin
- Participants see each other's display names only
- Match assignments are never visible to anyone except:
- The gifter (sees their own recipient)
- The admin (for troubleshooting/manual assignment only)
---
## Admin Account Management
### Initial Setup
- First-time setup creates the admin account
- Requires email and password
### Password Recovery
- Admin can request password reset via email
- Reset link sent to admin email address
---
## Out of Scope
The following features are explicitly **not** included in Sneaky Klaus:
- **Multiple admin accounts**: Single admin only
- **Participant-to-participant messaging**: No anonymous or direct messaging between participants
- **Reveal feature**: No mechanism to reveal who was whose Secret Santa after the exchange
- **Payment processing**: No built-in payment or gift card purchasing
- **Gift tracking**: No tracking of whether gifts were purchased or delivered
- **Social features**: No comments, reactions, or social sharing within the app
- **Wishlist links**: No integration with external wishlists (Amazon, etc.)
- **Calendar integration**: No .ics exports or calendar app integration
- **Multi-language support**: English only (initial release)
- **Themeing/customization**: No custom branding per exchange
---
## Success Metrics
A successful implementation of Sneaky Klaus will:
1. Allow an admin to create an exchange and have participants registered within 5 minutes
2. Enable participants to complete registration in under 60 seconds
3. Successfully match any valid participant set (where exclusions don't make matching impossible)
4. Deliver all notification emails reliably via Resend
5. Work smoothly on mobile devices without requiring a native app
6. Maintain complete secrecy of match assignments until intentionally revealed by gifters
---
## Glossary
| Term | Definition |
|------|------------|
| Exchange | A single Secret Santa event with its own participants, dates, and matches |
| Participant | A person registered in an exchange who will give and receive a gift |
| Match/Assignment | The pairing of a gifter to their recipient |
| Exclusion | A rule preventing two specific participants from being matched |
| Magic Link | A single-use, time-limited URL sent via email for passwordless authentication |
| Registration Link | The shareable URL used to join an exchange |

321
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,321 @@
# Sneaky Klaus - Implementation Roadmap
## Overview
This roadmap defines the phased implementation approach for Sneaky Klaus, a self-hosted Secret Santa organization application. The roadmap progresses from foundational infrastructure through core features to enhancement and polish.
## Key Architectural Decisions
The following decisions have been approved and are fixed:
- **Frontend**: Pure server-side rendering with Jinja2 templates (no JavaScript framework)
- **Background Jobs**: APScheduler (in-process, no separate job queue)
- **Password Requirements**: Minimum 12 characters, no complexity requirements
- **Magic Link Expiration**: 1 hour for links, 7-day sliding window for sessions
- **Reminder Defaults**: 7 days and 1 day before exchange date (configurable per exchange)
- **Minimum Participants**: Hard requirement of 3 participants per exchange
## Phase Definitions
### Phase 0: Foundation and Architecture
**Goal**: Establish technical foundation and architectural design
**Deliverables**:
- Architecture Decision Records (ADRs) for core technology choices
- System design documentation (v0.1.0)
- Database schema design
- API/route specifications
- Component design documentation
- Development environment setup
**Stories**: None (pre-implementation architectural work)
**Dependencies**: None
**MVP Status**: Prerequisite for MVP
---
### Phase 1: Core Admin & Exchange Management (MVP)
**Goal**: Enable admin to create and manage gift exchanges
**Stories**:
- 1.1 Initial Admin Setup
- 1.2 Admin Login
- 1.4 Admin Logout
- 2.1 Create Exchange
- 2.2 View Exchange List
- 2.3 View Exchange Details
- 2.6 Generate Registration Link
- 3.1 Open Registration
**Dependencies**: Phase 0 complete
**MVP Status**: **This is the MVP**
**Exit Criteria**:
- Admin can create account, log in, and log out
- Admin can create exchanges with all required fields
- Admin can view list of exchanges
- Admin can generate and copy registration links
- Admin can open registration for exchanges
---
### Phase 2: Participant Registration & Authentication
**Goal**: Enable participants to join exchanges and authenticate
**Stories**:
- 4.1 Access Registration Page
- 4.2 New Participant Registration
- 4.3 Returning Participant Detection
- 5.1 Magic Link Request
- 5.2 Magic Link Login
- 5.3 Participant Session
- 10.1 Registration Confirmation Email
**Dependencies**: Phase 1 complete
**Exit Criteria**:
- Participants can access registration page via link
- Participants can register with name, email, and gift ideas
- Returning participants are detected and can request magic link
- Magic links authenticate participants correctly
- Participant sessions persist appropriately
- Confirmation emails sent successfully
---
### Phase 3: Exchange State Management & Matching
**Goal**: Enable complete exchange lifecycle from registration to matching
**Stories**:
- 2.4 Edit Exchange
- 3.2 Close Registration
- 3.3 Reopen Registration (Pre-Matching)
- 7.1 View Participants for Exclusions
- 7.2 Add Exclusion Rule
- 7.3 Remove Exclusion Rule
- 7.4 Exclusion Validation
- 8.1 Trigger Matching
- 8.2 Matching Algorithm
- 8.3 Matching Failure Handling
- 8.4 View Matches (Admin)
- 10.2 Match Notification Email
**Dependencies**: Phase 2 complete
**Exit Criteria**:
- Admin can edit exchange details
- Admin can close and reopen registration
- Admin can configure exclusion rules
- Matching algorithm successfully creates valid assignments
- Match notification emails sent to all participants
- Admin can view all matches for troubleshooting
---
### Phase 4: Participant Dashboard & Self-Management
**Goal**: Enable participants to view assignments and manage profiles
**Stories**:
- 4.5 View Participant List (Pre-Matching)
- 6.1 Update Profile
- 6.2 Withdraw from Exchange
- 6.3 Update Reminder Preferences
- 11.1 View My Assignment
- 11.2 View Exchange Information
- 11.3 View Participant List (Post-Matching)
**Dependencies**: Phase 3 complete
**Exit Criteria**:
- Participants can view and update their profiles
- Participants can withdraw before registration closes
- Participants can view their assignment after matching
- Participants can view exchange information and participant lists
- Participants can manage reminder preferences
---
### Phase 5: Advanced Matching & Admin Features
**Goal**: Handle edge cases and provide advanced administrative control
**Stories**:
- 2.5 Delete Exchange
- 3.4 Reopen Registration (Post-Matching)
- 4.4 Admin Self-Registration
- 8.5 Re-Match Participants
- 8.6 Manual Match Override
- 9.1 Remove Participant (Admin)
- 9.2 Handle Post-Match Participant Removal
**Dependencies**: Phase 4 complete
**Exit Criteria**:
- Admin can delete exchanges
- Admin can reopen registration after matching (with match clearing)
- Admin can participate in their own exchanges
- Admin can trigger re-matching
- Admin can manually override individual matches
- Admin can remove participants with appropriate re-matching
---
### Phase 6: Notifications & Reminders
**Goal**: Complete notification system with reminders and admin notifications
**Stories**:
- 10.3 Reminder Emails
- 10.4 Admin Notification: New Registration
- 10.5 Admin Notification: Participant Withdrawal
- 10.6 Admin Notification: Matching Complete
- 10.7 Configure Admin Notifications
- 14.1 Configure Reminder Schedule
**Dependencies**: Phase 4 complete (can run parallel to Phase 5)
**Exit Criteria**:
- Reminder emails sent according to configured schedule
- Admin receives opt-in notifications for key events
- Admin can configure notification preferences globally and per-exchange
- Admin can configure reminder schedule per exchange
---
### Phase 7: Data Management & Lifecycle
**Goal**: Implement data retention and exchange lifecycle completion
**Stories**:
- 1.3 Admin Password Recovery
- 3.5 Mark Exchange Complete
- 3.6 Automatic Exchange Completion
- 12.1 Automatic Data Purge
- 12.2 View Data Retention Status
**Dependencies**: Phase 5 complete
**Exit Criteria**:
- Admin can recover password via email
- Exchanges automatically complete after exchange date
- Exchange data purged 30 days after completion
- Admin can view retention status and purge timeline
- Admin receives warning before data purge
---
### Phase 8: Polish & Responsive Design
**Goal**: Ensure excellent user experience across all devices
**Stories**:
- 13.1 Mobile-Friendly Participant Experience
- 13.2 Mobile-Friendly Admin Experience
**Dependencies**: Phase 7 complete
**Exit Criteria**:
- All participant flows work well on mobile devices
- All admin flows work well on mobile devices
- Touch targets appropriately sized
- No horizontal scrolling required
- Tested across common mobile browsers
---
## Phase Dependencies Graph
```
Phase 0 (Foundation)
Phase 1 (MVP: Core Admin & Exchange Management)
Phase 2 (Participant Registration & Authentication)
Phase 3 (Exchange State Management & Matching)
Phase 4 (Participant Dashboard & Self-Management)
├── Phase 5 (Advanced Matching & Admin Features)
└── Phase 6 (Notifications & Reminders) [parallel]
Phase 7 (Data Management & Lifecycle)
Phase 8 (Polish & Responsive Design)
```
## MVP Definition
**The MVP is Phase 1**: Core Admin & Exchange Management
An implementation reaches MVP when:
1. An admin can create an account
2. An admin can log in and out securely
3. An admin can create gift exchanges with all required fields
4. An admin can view a list of their exchanges
5. An admin can generate shareable registration links
6. The application is deployable via Docker
This represents the minimum foundation for a functional Secret Santa application, even though it doesn't yet support participant registration or matching.
## Implementation Notes
### Story Prioritization Within Phases
Stories listed within each phase are roughly prioritized, but developers should:
- Implement database models and schemas before UI
- Implement authentication before protected routes
- Consider natural groupings (e.g., all CRUD operations for a feature together)
### Testing Strategy
Each phase should include:
- Unit tests for business logic
- Integration tests for database operations
- End-to-end tests for critical user flows
- Tests written before or alongside implementation (TDD encouraged)
### Documentation Updates
As each phase completes:
- Update design docs if implementation reveals necessary changes
- Document any deviations from original design with rationale
- Update ADRs if architectural decisions change
### Deployment Considerations
- Docker image should be buildable and runnable from Phase 1 onward
- Each phase should result in a deployable, functional (if limited) application
- Database migrations should be reversible where possible
## Success Criteria
The implementation is complete when:
1. All 8 phases are delivered
2. All acceptance criteria for all stories are met
3. Application passes end-to-end testing
4. Documentation is complete and accurate
5. Application is production-ready for self-hosting
## Timeline Estimates
Actual timeline depends on development velocity. Rough estimates:
- Phase 0: 2-3 days (design work)
- Phase 1: 3-5 days (MVP)
- Phase 2: 3-4 days
- Phase 3: 5-7 days (complex matching logic)
- Phase 4: 2-3 days
- Phase 5: 3-4 days
- Phase 6: 2-3 days
- Phase 7: 2-3 days
- Phase 8: 2-3 days
**Total estimate**: 24-37 days of development time

View File

@@ -0,0 +1,179 @@
# 0001. Core Technology Stack
Date: 2025-12-22
## Status
Accepted (Updated 2025-12-22)
## Context
Sneaky Klaus is a self-hosted Secret Santa organization application designed for individuals, families, and small organizations who want full control over their data. The application must be:
- Easy to self-host via containerization
- Simple to deploy and maintain
- Minimal in external dependencies
- Suitable for small-scale usage (dozens of participants, not thousands)
- Functional without complex infrastructure
Key requirements that inform technology choices:
1. **Self-hosting first**: Users should be able to deploy with a single container
2. **Simplicity**: The tech stack should be straightforward and well-documented
3. **No participant accounts**: Authentication must support passwordless magic links
4. **Email delivery**: Must send transactional emails reliably
5. **Background jobs**: Must handle scheduled tasks (reminders, data purging)
6. **Data persistence**: Must store user data reliably but doesn't need high-scale database features
## Decision
We will use the following core technology stack:
| Component | Technology | Version Constraint |
|-----------|------------|-------------------|
| **Backend Framework** | Flask | ^3.0 |
| **Language** | Python | ^3.11 |
| **Database** | SQLite | ^3.40 (via Python stdlib) |
| **Email Service** | Resend | Latest SDK |
| **Deployment** | Docker | Latest |
| **Package Manager** | uv | Latest |
| **Background Jobs** | APScheduler | ^3.10 |
| **Template Engine** | Jinja2 | ^3.1 (Flask default) |
| **WSGI Server** | Gunicorn | ^21.0 (production) |
| **Form Handling** | Flask-WTF (includes WTForms) | ^1.2 |
| **Session Management** | Flask-Session | ^0.8 |
| **Timezone Validation** | pytz | Latest |
| **CSS Framework** | Pico CSS | Latest (via CDN) |
### Key Technology Rationale
**Flask**: Lightweight, well-documented, excellent for small-to-medium applications. Large ecosystem, straightforward patterns, and no unnecessary complexity.
**Python 3.11+**: Modern Python with performance improvements, excellent type hinting support, and active security support.
**SQLite**: Perfect for self-hosted applications. Zero-configuration, single-file database, excellent for read-heavy workloads with occasional writes. Eliminates need for separate database server.
**Resend**: Modern transactional email API with excellent deliverability, simple API, and reasonable pricing for small-scale usage.
**Docker**: Industry-standard containerization. Single container deployment simplifies self-hosting significantly.
**uv**: Fast, modern Python package manager and project manager. Significantly faster than pip, with better dependency resolution and lockfile support.
**APScheduler**: In-process job scheduling. Eliminates need for separate job queue infrastructure (Redis, Celery) while still supporting background tasks like reminder emails and data purging.
**Jinja2**: Flask's default templating engine. Server-side rendering eliminates need for frontend JavaScript framework, simplifying deployment and maintenance.
**Gunicorn**: Production-ready WSGI server for Flask applications. Well-tested, stable, and appropriate for the scale of this application.
**Flask-WTF**: Integrates WTForms with Flask, providing form validation, CSRF protection, and secure form handling. Industry-standard for Flask applications.
**Flask-Session**: Server-side session management for Flask. Stores session data in SQLite, providing secure session handling without client-side storage concerns.
**pytz**: Standard Python library for timezone validation and handling. Required for validating IANA timezone names in exchange configurations.
**Pico CSS**: Minimal, classless CSS framework delivered via CDN. Provides clean, semantic styling without requiring a build step or complex class names. Fully responsive and accessible out of the box.
### Frontend Approach
**Pure server-side rendering** with Jinja2 templates. No JavaScript framework (React, Vue, etc.). This decision:
- Eliminates build tooling complexity
- Reduces deployment artifacts (no separate frontend bundle)
- Simplifies security (no client-side state management)
- Ensures full functionality without JavaScript enabled
- Maintains mobile-friendliness through responsive CSS
Progressive enhancement with minimal JavaScript for interactivity (copy-to-clipboard, form validation) is acceptable but not required for core functionality.
## Consequences
### Positive
- **Simple deployment**: Single container with no external service dependencies (except Resend for email)
- **Low resource requirements**: SQLite and in-process job scheduling minimize memory and CPU usage
- **Fast development**: Flask's simplicity and Jinja2's straightforward templating accelerate development
- **Easy debugging**: All code runs in single process, simplifying troubleshooting
- **Predictable performance**: Server-side rendering is fast and consistent
- **No build step**: Templates render directly; no frontend compilation required
- **Security by default**: Server-side rendering reduces attack surface compared to client-side SPAs
- **Excellent for scale target**: Perfect for dozens to hundreds of participants per deployment
### Negative
- **SQLite limitations**: Not suitable if application needs to scale to thousands of concurrent users (not a concern for target use case)
- **No horizontal scaling**: Single SQLite file prevents multi-instance deployment (acceptable trade-off for simplicity)
- **Email vendor lock-in**: Resend is the only supported email provider (could be abstracted later if needed)
- **APScheduler constraints**: Job scheduling tied to application process lifetime; jobs don't survive application restarts (acceptable for reminder scheduling)
- **Less interactive UI**: Server-side rendering means no SPA-style instant interactivity (acceptable trade-off for simplicity)
### Neutral
- **Python expertise required**: Development requires Python knowledge (expected for Flask application)
- **Database portability**: SQLite schema could be migrated to PostgreSQL if scaling needs change, but would require development effort
- **Email testing**: Requires Resend account for development (free tier available) or mocking in tests
## Implementation Notes
### Database Considerations
SQLite will be configured with:
- WAL (Write-Ahead Logging) mode for better concurrency
- Foreign keys enabled
- Appropriate timeout for locked database scenarios
- Regular backups recommended via volume mounts
### Job Scheduling Considerations
APScheduler will run in-process with:
- JobStore backed by SQLite for job persistence across restarts (for scheduled jobs)
- Executor using thread pool for background tasks
- Misfire grace time configured appropriately for reminders
### Email Configuration
Resend integration will:
- Store API key in environment variable (not in code)
- Support template-based emails
- Handle failures gracefully with logging
- Rate limit appropriately
### Development vs Production
- **Development**: Flask development server, SQLite in local file
- **Production**: Gunicorn with multiple workers, SQLite in mounted volume, proper logging
## Alternatives Considered
### Database Alternatives
**PostgreSQL**: More scalable but requires separate database container/service, significantly complicating self-hosting. Overkill for target scale.
**MySQL/MariaDB**: Same drawbacks as PostgreSQL for this use case.
### Job Queue Alternatives
**Celery + Redis**: More robust job processing but requires Redis container, significantly complicating deployment. Overkill for reminder emails and daily data purging tasks.
**Cron + separate script**: Could work but fragments application logic and complicates deployment.
### Email Service Alternatives
**SendGrid**: Viable alternative but more complex API and pricing structure.
**Amazon SES**: Requires AWS account and more complex setup. Higher barrier for self-hosters.
**SMTP**: Requires users to configure their own SMTP server, significantly increasing setup complexity and deliverability issues.
### Frontend Alternatives
**React/Vue SPA**: Considered but rejected. Would require build tooling, increase deployment complexity, and provide minimal benefit for the application's relatively simple UI needs.
**HTMX**: Considered for progressive enhancement. May be added later but not required for MVP.
## References
- Flask documentation: https://flask.palletsprojects.com/
- SQLite documentation: https://www.sqlite.org/docs.html
- Resend documentation: https://resend.com/docs
- APScheduler documentation: https://apscheduler.readthedocs.io/
- uv documentation: https://docs.astral.sh/uv/

View File

@@ -0,0 +1,298 @@
# 0002. Authentication Strategy
Date: 2025-12-22
## Status
Accepted
## Context
Sneaky Klaus has two distinct user types with different authentication needs:
1. **Administrator**: Single admin account for entire installation. Needs persistent access to manage exchanges. Must be able to recover access if password is forgotten.
2. **Participants**: Multiple participants across multiple exchanges. Should have frictionless authentication without password management burden. Same participant may join multiple exchanges using same email.
Key requirements:
- **Security**: Authentication must be secure and follow best practices
- **Simplicity for participants**: No password required; minimal friction to access information
- **Admin control**: Admin needs traditional authenticated session for management tasks
- **Password recovery**: Admin must be able to recover access via email
- **Session management**: Sessions should persist appropriately but expire for security
- **Email verification**: Participant email addresses must be verified (implicit via magic link)
## Decision
We will implement a **dual authentication strategy**:
### Admin Authentication: Password-Based
**Login Flow**:
1. Admin enters email and password
2. Password hashed with bcrypt, compared to stored hash
3. On success, session created with admin role
4. Session cookie set with appropriate security flags
**Password Requirements**:
- Minimum 12 characters
- No complexity requirements (no mandatory special chars, numbers, etc.)
- This follows modern NIST guidance: length matters more than complexity
**Password Recovery Flow**:
1. Admin requests password reset from login page
2. System sends time-limited reset token (1 hour expiration) to admin email
3. Reset link directs to password reset form
4. Token validated, new password set
5. Token invalidated after single use
**Session Management**:
- Server-side sessions stored in database or cache
- 7-day sliding expiration window (extends on activity)
- Secure, HTTP-only session cookies
- SameSite=Lax for CSRF protection
- Logout explicitly destroys session
### Participant Authentication: Magic Links
**Magic Link Flow**:
1. Participant requests access (from registration page or email)
2. System generates cryptographically random token (256-bit)
3. Token stored in database with 1-hour expiration
4. Email sent with magic link: `/participant/auth/{token}`
5. Clicking link validates token and creates session
6. Token invalidated after single use
**Session Management**:
- Server-side sessions stored in database
- 7-day sliding expiration window (extends on activity)
- Secure, HTTP-only session cookies
- SameSite=Lax for CSRF protection
- Sessions scoped to participant's exchanges only
- No explicit logout needed (session expires naturally)
**Token Generation**:
- Use Python's `secrets` module for cryptographic randomness
- Tokens are 32-byte random values, URL-safe base64 encoded
- Tokens stored as hashed values in database (using SHA-256)
- Original token never stored in plain text
### Security Measures
**Password Storage**:
- bcrypt with cost factor 12 (adjustable)
- Passwords never logged or exposed in error messages
- Password reset tokens hashed before storage
**Session Security**:
- Session IDs are cryptographically random
- Sessions stored server-side (not client-side JWTs)
- Session data includes: user ID, role (admin/participant), creation time, last activity
- Cookie flags: `Secure=True` (HTTPS only), `HttpOnly=True`, `SameSite=Lax`
**Rate Limiting**:
- Login attempts: 5 per email per 15 minutes
- Magic link requests: 3 per email per hour
- Password reset requests: 3 per email per hour
- Implemented at application level, tracked in database or cache
**Token Expiration**:
- Magic link tokens: 1 hour
- Password reset tokens: 1 hour
- Admin sessions: 7 days (sliding window)
- Participant sessions: 7 days (sliding window)
## Consequences
### Positive
- **Participant convenience**: No password to remember; access via email
- **Email verification**: Magic links implicitly verify participant email addresses
- **Admin security**: Traditional password-based auth provides familiar security model
- **Password recovery**: Admin can self-serve password reset without external support
- **Sliding sessions**: Activity extends session, reducing re-authentication friction
- **Security best practices**: Modern password requirements (length over complexity)
- **CSRF protection**: SameSite cookies prevent cross-site request forgery
- **Token security**: One-time-use tokens prevent replay attacks
### Negative
- **Email dependency**: Magic links require working email delivery (mitigated by Resend reliability)
- **Token expiration UX**: 1-hour expiration may frustrate slow email checkers (acceptable trade-off for security)
- **Session storage**: Server-side sessions require database/cache storage (minimal overhead)
- **No remember-me for admin**: 7-day max session requires re-login (acceptable for security)
### Neutral
- **Dual auth complexity**: Maintaining two auth flows adds implementation complexity (necessary for different user needs)
- **Rate limiting overhead**: Requires tracking attempts per user (minimal performance impact)
- **Session cleanup**: Expired sessions must be periodically purged (handled via background job)
## Implementation Details
### Database Schema
**Admin User**:
```python
class Admin(Model):
id: int
email: str (unique, indexed)
password_hash: str
created_at: datetime
updated_at: datetime
```
**Participant** (simplified for auth):
```python
class Participant(Model):
id: int
email: str (indexed)
exchange_id: int (foreign key)
# ... other fields
```
**Session**:
```python
class Session(Model):
id: str (session ID, primary key)
user_id: int
user_type: str ('admin' | 'participant')
created_at: datetime
last_activity: datetime
expires_at: datetime
data: JSON (optional additional session data)
```
**Auth Token** (magic links and password reset):
```python
class AuthToken(Model):
id: int
token_hash: str (indexed)
token_type: str ('magic_link' | 'password_reset')
email: str
participant_id: int (nullable, for magic links)
exchange_id: int (nullable, for magic links)
created_at: datetime
expires_at: datetime
used_at: datetime (nullable)
```
**Rate Limit**:
```python
class RateLimit(Model):
id: int
key: str (e.g., "login:admin@example.com", indexed)
attempts: int
window_start: datetime
expires_at: datetime
```
### Flask Session Configuration
```python
app.config['SESSION_TYPE'] = 'sqlalchemy' # Server-side sessions
app.config['SESSION_PERMANENT'] = True
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_REFRESH_EACH_REQUEST'] = True # Sliding window
```
### Authentication Decorators
```python
@login_required # Requires any authenticated user
@admin_required # Requires admin role
@participant_required # Requires participant role
```
### URL Structure
**Admin**:
- `/admin/login` - Login form
- `/admin/logout` - Logout
- `/admin/forgot-password` - Request password reset
- `/admin/reset-password/{token}` - Reset password form
**Participant**:
- `/participant/auth/{token}` - Magic link endpoint
- `/participant/logout` - Optional logout
## Alternatives Considered
### OAuth/Social Login
**Rejected**: Adds external dependencies, complicates self-hosting, and provides minimal benefit for a self-hosted application where users control the deployment.
### JWT Tokens
**Rejected for sessions**: JWTs are stateless, making them difficult to invalidate (e.g., on logout or security incident). Server-side sessions provide better control.
**Considered for magic links**: Could use JWTs for magic links, but custom tokens are simpler and equally secure.
### Passkeys/WebAuthn
**Deferred**: Modern and secure but adds implementation complexity. Could be added in future version for admin auth.
### Email Verification Codes
**Rejected**: 6-digit codes are less secure than magic links and require users to manually copy/paste, reducing convenience.
### Participant Passwords
**Rejected**: Violates core principle of frictionless participant experience. Participants joining Secret Santa events shouldn't need to manage yet another password.
### Longer Magic Link Expiration
**Rejected**: 1 hour balances security with usability. Longer expiration increases risk if email account is compromised.
### Shorter Session Duration
**Considered**: 24-hour sessions would be more secure but require frequent re-authentication. 7-day sliding window balances security with convenience.
## Security Considerations
### Password Reset Token Timing Attack
To prevent email enumeration via timing attacks:
- Always show "If an account exists, you'll receive an email" message
- Perform same-time operations regardless of email existence
- Don't reveal whether email is registered
### Magic Link Security
- Tokens are single-use and time-limited
- Token hashing prevents database compromise from exposing valid tokens
- Rate limiting prevents brute force token guessing
- Tokens scoped to specific participant and exchange
### Session Fixation Prevention
- New session ID generated on login
- Old session destroyed on logout
- Session ID rotated on privilege elevation
### Brute Force Protection
- Rate limiting on all auth endpoints
- Progressive delays on repeated failures (optional enhancement)
- Account lockout not implemented (single admin, participant magic links)
## Future Enhancements
Potential improvements for future versions:
1. **Admin 2FA**: Time-based OTP for additional admin security
2. **Passkeys**: WebAuthn support for passwordless admin auth
3. **Session device tracking**: Show admin active sessions and allow revocation
4. **Remember-me for admin**: Optional extended session with re-authentication for sensitive actions
5. **Magic link preview protection**: Use confirmation step before activating magic link
## References
- NIST Password Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Flask Session Management: https://flask.palletsprojects.com/en/latest/quickstart/#sessions
- Python secrets module: https://docs.python.org/3/library/secrets.html

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from sneaky-klaus!")
if __name__ == "__main__":
main()

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

75
migrations/env.py Normal file
View File

@@ -0,0 +1,75 @@
"""Alembic migration environment configuration.
This module configures Alembic to work with the Flask application
and SQLAlchemy models.
"""
from logging.config import fileConfig
from alembic import context
# Import Flask app and database before configuring Alembic
from src.app import create_app, db
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Configure database URL from Flask config
app = create_app()
config.set_main_option("sqlalchemy.url", app.config["SQLALCHEMY_DATABASE_URI"])
# Add your model's MetaData object here for 'autogenerate' support
target_metadata = db.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Use the Flask app's engine instead of creating a new one
with app.app_context():
connectable = db.engine
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,29 @@
"""Initial schema with Admin and Exchange models
Revision ID: eeff6e1a89cd
Revises:
Create Date: 2025-12-22 11:24:00.652946
"""
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "eeff6e1a89cd"
down_revision: str | Sequence[str] | None = None
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 ###

93
pyproject.toml Normal file
View File

@@ -0,0 +1,93 @@
[project]
name = "sneaky-klaus"
version = "0.1.0"
description = "A self-hosted Secret Santa organization application"
requires-python = ">=3.11"
dependencies = [
"flask>=3.0",
"flask-wtf>=1.2",
"flask-sqlalchemy>=3.0",
"flask-session>=0.8",
"flask-bcrypt>=1.0",
"gunicorn>=21.0",
"sqlalchemy>=2.0",
"alembic>=1.12",
"email-validator>=2.0",
"resend>=0.7",
"apscheduler>=3.10",
"pytz>=2023.3",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"pytest-flask>=1.2",
"ruff>=0.8.0",
"mypy>=1.13.0",
"pre-commit>=3.0",
"types-flask>=1.1",
"types-pytz>=2023.3",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = [
"--cov=src",
"--cov-report=term-missing",
"--cov-fail-under=80",
"-v",
]
[tool.coverage.run]
source = ["src"]
branch = true
omit = [
"*/tests/*",
"*/migrations/*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
ignore_missing_imports = true

0
src/__init__.py Normal file
View File

143
src/app.py Normal file
View File

@@ -0,0 +1,143 @@
"""Flask application factory for Sneaky Klaus.
This module provides the application factory function that creates
and configures the Flask application instance.
"""
from pathlib import Path
from flask import Flask
from flask_bcrypt import Bcrypt
from flask_session import Session
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
# Initialize Flask extensions (without app instance)
db = SQLAlchemy()
bcrypt = Bcrypt()
csrf = CSRFProtect()
session = Session()
def create_app(config_name: str | None = None) -> Flask:
"""Create and configure the Flask application.
Args:
config_name: Configuration environment name (development, production, testing).
If None, uses FLASK_ENV environment variable.
Returns:
Configured Flask application instance.
"""
# Create Flask app instance
app = Flask(__name__)
# Load configuration
from src.config import ProductionConfig, get_config
config_class = get_config(config_name)
app.config.from_object(config_class)
# Validate production config if needed
if config_name == "production":
ProductionConfig.validate()
# Ensure data directory exists
data_dir = Path(app.config["DATA_DIR"])
data_dir.mkdir(parents=True, exist_ok=True)
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
csrf.init_app(app)
# Initialize session with SQLAlchemy backend
app.config["SESSION_SQLALCHEMY"] = db
session.init_app(app)
# Register blueprints (will be created later)
# from src.routes import admin, auth, participant, public
# app.register_blueprint(admin.bp)
# app.register_blueprint(auth.bp)
# app.register_blueprint(participant.bp)
# app.register_blueprint(public.bp)
# Register error handlers
register_error_handlers(app)
# First-run setup check
register_setup_check(app)
# Import models to ensure they're registered with SQLAlchemy
with app.app_context():
from src.models import Admin, Exchange # noqa: F401
db.create_all()
return app
def register_error_handlers(app: Flask) -> None:
"""Register custom error handlers.
Args:
app: Flask application instance.
"""
from flask import render_template
@app.errorhandler(404)
def not_found_error(_error):
"""Handle 404 Not Found errors."""
return render_template("errors/404.html"), 404
@app.errorhandler(403)
def forbidden_error(_error):
"""Handle 403 Forbidden errors."""
return render_template("errors/403.html"), 403
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 Internal Server Error."""
db.session.rollback() # Rollback any failed transactions
app.logger.error(f"Internal server error: {error}")
return render_template("errors/500.html"), 500
@app.errorhandler(429)
def rate_limit_error(_error):
"""Handle 429 Too Many Requests errors."""
from flask import flash, redirect, request
flash("Too many attempts. Please try again later.", "error")
return redirect(request.referrer or "/"), 429
def register_setup_check(app: Flask) -> None:
"""Register before_request handler for first-run setup detection.
Args:
app: Flask application instance.
"""
from flask import redirect, request, url_for
@app.before_request
def check_setup_required():
"""Redirect to setup page if no admin exists.
This runs before every request to ensure the application
has been set up with an admin account.
"""
# Skip check for certain endpoints
if request.endpoint in ["setup", "static", "health"]:
return
# Check if we've already determined setup is required
if not hasattr(app, "_setup_checked"):
from src.models.admin import Admin
admin_count = db.session.query(Admin).count()
app.config["REQUIRES_SETUP"] = admin_count == 0
app._setup_checked = True
# Redirect to setup if needed
if app.config.get("REQUIRES_SETUP") and request.endpoint != "setup":
return redirect(url_for("setup"))

120
src/config.py Normal file
View File

@@ -0,0 +1,120 @@
"""Configuration management for Sneaky Klaus application.
This module defines configuration classes for different environments
(development, production, testing) with environment variable support.
"""
import os
from datetime import timedelta
from pathlib import Path
class Config:
"""Base configuration class with common settings."""
# Base paths
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / "data"
# Security
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
# Database
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", f"sqlite:///{DATA_DIR / 'sneaky-klaus.db'}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Session management
SESSION_TYPE = "sqlalchemy"
SESSION_PERMANENT = True
SESSION_USE_SIGNER = True
SESSION_KEY_PREFIX = "sk:"
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# Flask-WTF CSRF Protection
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None # No time limit for CSRF tokens
# Email service (Resend)
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
# Application URLs
APP_URL = os.environ.get("APP_URL", "http://localhost:5000")
# Timezone
TIMEZONE = os.environ.get("TZ", "UTC")
# Password requirements
MIN_PASSWORD_LENGTH = 12
class DevelopmentConfig(Config):
"""Development environment configuration."""
DEBUG = True
TESTING = False
SESSION_COOKIE_SECURE = False # Allow HTTP in development
SQLALCHEMY_ECHO = True # Log SQL queries
class ProductionConfig(Config):
"""Production environment configuration."""
DEBUG = False
TESTING = False
# Ensure critical environment variables are set
@classmethod
def validate(cls):
"""Validate that required production configuration is present."""
required_vars = ["SECRET_KEY", "RESEND_API_KEY", "APP_URL"]
missing_vars = [var for var in required_vars if not os.environ.get(var)]
if missing_vars:
raise ValueError(
f"Missing required environment variables: {', '.join(missing_vars)}"
)
class TestConfig(Config):
"""Test environment configuration."""
TESTING = True
DEBUG = True
SESSION_COOKIE_SECURE = False
# Use in-memory database for tests
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
# Disable CSRF for easier testing
WTF_CSRF_ENABLED = False
# Use a predictable secret key for tests
SECRET_KEY = "test-secret-key"
# Configuration dictionary for easy access
config = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"testing": TestConfig,
"default": DevelopmentConfig,
}
def get_config(env: str | None = None) -> type[Config]:
"""Get configuration class based on environment.
Args:
env: Environment name (development, production, testing).
If None, uses FLASK_ENV environment variable.
Returns:
Configuration class for the specified environment.
"""
if env is None:
env = os.environ.get("FLASK_ENV", "development")
return config.get(env, config["default"])

9
src/models/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""SQLAlchemy models for Sneaky Klaus.
This package contains all database models used by the application.
"""
from src.models.admin import Admin
from src.models.exchange import Exchange
__all__ = ["Admin", "Exchange"]

48
src/models/admin.py Normal file
View File

@@ -0,0 +1,48 @@
"""Admin model for Sneaky Klaus.
The Admin model represents the single administrator account
for the entire installation.
"""
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from src.app import db
class Admin(db.Model): # type: ignore[name-defined]
"""Administrator user model.
Represents the single admin account for the entire Sneaky Klaus installation.
Only one admin should exist per deployment.
Attributes:
id: Auto-increment primary key.
email: Admin email address (unique, indexed).
password_hash: bcrypt password hash.
created_at: Account creation timestamp.
updated_at: Last update timestamp.
"""
__tablename__ = "admin"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
def __repr__(self) -> str:
"""String representation of Admin instance."""
return f"<Admin {self.email}>"

98
src/models/exchange.py Normal file
View File

@@ -0,0 +1,98 @@
"""Exchange model for Sneaky Klaus.
The Exchange model represents a single Secret Santa exchange event.
"""
import secrets
import string
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from src.app import db
class Exchange(db.Model): # type: ignore[name-defined]
"""Exchange (Secret Santa event) model.
Represents a single Secret Santa exchange with all configuration
and state management.
Attributes:
id: Auto-increment primary key.
slug: URL-safe unique identifier (12 characters).
name: Exchange name/title.
description: Optional description.
budget: Gift budget (freeform text, e.g., "$20-30").
max_participants: Maximum participant limit (minimum 3).
registration_close_date: When registration ends.
exchange_date: When gifts are exchanged.
timezone: IANA timezone name (e.g., "America/New_York").
state: Current state (draft, registration_open, etc.).
created_at: Exchange creation timestamp.
updated_at: Last update timestamp.
completed_at: When exchange was marked complete.
"""
__tablename__ = "exchange"
# Valid state transitions
STATE_DRAFT = "draft"
STATE_REGISTRATION_OPEN = "registration_open"
STATE_REGISTRATION_CLOSED = "registration_closed"
STATE_MATCHED = "matched"
STATE_COMPLETED = "completed"
VALID_STATES = [
STATE_DRAFT,
STATE_REGISTRATION_OPEN,
STATE_REGISTRATION_CLOSED,
STATE_MATCHED,
STATE_COMPLETED,
]
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(
String(12), unique=True, nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
budget: Mapped[str] = mapped_column(String(100), nullable=False)
max_participants: Mapped[int] = mapped_column(Integer, nullable=False)
registration_close_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
exchange_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
timezone: Mapped[str] = mapped_column(String(50), nullable=False)
state: Mapped[str] = mapped_column(
String(20), nullable=False, default=STATE_DRAFT, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, index=True
)
__table_args__ = (
CheckConstraint("max_participants >= 3", name="min_participants_check"),
)
@staticmethod
def generate_slug() -> str:
"""Generate a unique 12-character URL-safe slug.
Returns:
Random 12-character alphanumeric string.
"""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(12))
def __repr__(self) -> str:
"""String representation of Exchange instance."""
return f"<Exchange {self.name} ({self.slug})>"

1302
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff