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>
776 lines
26 KiB
Markdown
776 lines
26 KiB
Markdown
# 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)
|