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