Files
sneakyklaus/docs/designs/v0.2.0/data-model.md
Phil Skentelbery eaafa78cf3 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>
2025-12-22 16:23:47 -07:00

26 KiB

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

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:

  • draftregistration_open
  • registration_openregistration_closed
  • registration_closedregistration_open (reopen)
  • registration_closedmatched (after matching)
  • matchedregistration_open (reopen, clears matches)
  • matchedcompleted

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

# 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:

# 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

    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

    sqlite3 sneaky-klaus.db ".backup sneaky-klaus-backup.db"
    
  3. 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

  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