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>
26 KiB
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
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_emailonemail(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_atautomatically 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_slugonslug(unique)idx_exchange_stateonstateidx_exchange_exchange_dateonexchange_dateidx_exchange_completed_atoncompleted_at
States (enum enforced at application level):
draft: Exchange created but not accepting registrationsregistration_open: Participants can registerregistration_closed: Registration ended, ready for matchingmatched: Participants have been assigned recipientscompleted: Exchange date has passed
State Transitions:
draft→registration_openregistration_open→registration_closedregistration_closed→registration_open(reopen)registration_closed→matched(after matching)matched→registration_open(reopen, clears matches)matched→completed
Constraints:
registration_close_datemust be beforeexchange_date(validated at application level)max_participantsminimum value: 3- Timezone must be valid IANA timezone (validated at application level)
slugmust 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_idonexchange_ididx_participant_emailonemailidx_participant_exchange_emailon(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_atinstead 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_idonexchange_ididx_match_giver_idongiver_ididx_match_receiver_idonreceiver_ididx_match_exchange_giveron(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_idcannot equalreceiver_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_idonexchange_ididx_exclusion_participantson(exchange_id, participant_a_id, participant_b_id)(composite unique)
Constraints:
participant_a_idandparticipant_b_idmust 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_hashontoken_hash(unique)idx_magic_token_type_emailon(token_type, email)idx_magic_token_expires_atonexpires_at
Constraints:
token_typemust be 'magic_link' or 'password_reset' (enforced at application level)- For magic_link:
participant_idandexchange_idmust be NOT NULL - For password_reset:
participant_idandexchange_idmust be NULL - Tokens expire 1 hour after creation
- Tokens are single-use (validated via
used_at)
Token Generation:
- Token: 32-byte random value from
secretsmodule, 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_attimestamp, 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_keyonkey(unique)idx_rate_limit_expires_atonexpires_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:
- Check if key exists and within window
- If attempts exceeded: reject request
- If within limits: increment attempts
- 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_idonexchange_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_atmanaged 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:
draftregistration_openregistration_closedmatchedcompleted
UserType (Session):
adminparticipant
TokenType (MagicToken):
magic_linkpassword_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 | 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 | 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
- Eager Loading: Use SQLAlchemy
joinedload()for relationships to prevent N+1 queries - Batch Operations: Use bulk insert/update for matching operations
- Filtered Queries: Always filter by exchange_id first (most selective)
- Connection Pooling: Disabled for SQLite (single file, no benefit)
- WAL Mode: Enabled for better read concurrency
SQLite Configuration
# 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, requiredslug: Exactly 12 URL-safe alphanumeric characters, auto-generated, uniquebudget: 1-100 characters, required (freeform text)max_participants: ≥ 3, requiredregistration_close_date<exchange_date: validated at application leveltimezone: Valid IANA timezone name
Participant
name: 1-255 characters, requiredemail: Valid email format, unique per exchangegift_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_attimestamp - Most tables have
updated_attimestamp (auto-updated) - Soft deletes used where appropriate (
withdrawn_atfor participants)
Future Audit Enhancements
If detailed audit trail is needed:
- Create
audit_logtable - 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:
# Create initial migration
alembic revision --autogenerate -m "Initial schema"
# Apply migration
alembic upgrade head
Schema Versioning
- Alembic tracks migration version in
alembic_versiontable - Each schema change creates new migration file
- Migrations can be rolled back if needed
Future Schema Changes
When adding fields or tables:
- Create Alembic migration
- Test migration on copy of production database
- Run migration during deployment
- Update SQLAlchemy models
Sample Data Relationships
Example: Complete Exchange Flow
-
Exchange Created (state: draft)
- 1 exchange record created
-
Registration Opens (state: registration_open)
- Exchange state updated
- Participants register (3-100 participant records)
-
Registration Closes (state: registration_closed)
- Exchange state updated
- Admin adds exclusion rules (0-N exclusion_rule records)
-
Matching Occurs (state: matched)
- Exchange state updated
- Match records created (N matches for N participants)
- Magic tokens created for notifications
-
Exchange Completes (state: completed)
- Exchange state updated
completed_attimestamp set- 30-day retention countdown begins
-
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:
-
Volume Snapshot: Recommended for Docker deployments
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 -
SQLite Backup Command: Online backup without downtime
sqlite3 sneaky-klaus.db ".backup sneaky-klaus-backup.db" -
File Copy: Requires application shutdown
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
- Stop application container
- Replace database file with backup
- Restart application
- Verify health endpoint
- 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: UNIQUEexchange.slug: UNIQUEparticipant.(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: UNIQUErate_limit.key: UNIQUEnotification_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:
- Audit Log Table: Track all changes for compliance/debugging
- Email Queue Table: Decouple email sending from transactional operations
- Participant Groups: Support for couple/family groupings (exclude from each other automatically)
- Multiple Admins: Add admin roles and permissions
- Exchange Templates: Reusable exchange configurations
- Custom Fields: User-defined participant attributes
These enhancements would require schema changes but are out of scope for v0.1.0.